├── .dockerignore
├── .formatter.exs
├── .github
└── FUNDING.yml
├── .gitignore
├── Dockerfile
├── README.md
├── assets
├── .babelrc
├── css
│ └── app.scss
├── js
│ └── app.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── static
│ ├── favicon.ico
│ └── robots.txt
├── tailwind.config.js
└── webpack.config.js
├── config
├── config.exs
├── dev.exs
├── dev.secret.template.exs
├── prod.exs
├── releases.exs
└── test.exs
├── docker-compose.yml
├── fly.toml
├── lib
├── linear.ex
├── linear
│ ├── accounts.ex
│ ├── accounts
│ │ └── account.ex
│ ├── actions.ex
│ ├── actions
│ │ ├── add_github_labels.ex
│ │ ├── block_linear_private_issue.ex
│ │ ├── create_github_comment.ex
│ │ ├── create_github_issue.ex
│ │ ├── create_linear_comment.ex
│ │ ├── create_linear_issue.ex
│ │ ├── fetch_github_labels.ex
│ │ ├── fetch_linear_issue.ex
│ │ ├── fetch_linear_labels.ex
│ │ ├── helpers.ex
│ │ ├── remove_github_labels.ex
│ │ ├── update_github_issue.ex
│ │ └── update_linear_issue.ex
│ ├── api
│ │ ├── github_api.ex
│ │ ├── github_api
│ │ │ └── github_data.ex
│ │ ├── github_api_behaviour.ex
│ │ ├── linear_api.ex
│ │ ├── linear_api
│ │ │ ├── linear_data.ex
│ │ │ └── session.ex
│ │ └── linear_api_behaviour.ex
│ ├── application.ex
│ ├── auth
│ │ ├── github.ex
│ │ └── github_app.ex
│ ├── data.ex
│ ├── data
│ │ ├── issue_sync.ex
│ │ ├── shared_issue.ex
│ │ └── shared_issue_lock.ex
│ ├── issue_sync_service.ex
│ ├── linear_query.ex
│ ├── release.ex
│ ├── repo.ex
│ ├── synchronize.ex
│ ├── synchronize
│ │ ├── content_writer.ex
│ │ ├── event.ex
│ │ └── sync_engine.ex
│ ├── util.ex
│ ├── webhooks.ex
│ └── webhooks
│ │ ├── github_webhook.ex
│ │ └── linear_webhook.ex
├── linear_web.ex
└── linear_web
│ ├── components
│ ├── core_components.ex
│ ├── layouts.ex
│ └── layouts
│ │ ├── app.html.heex
│ │ └── root.html.heex
│ ├── controllers
│ ├── account_controller.ex
│ ├── auth_github_app_controller.ex
│ ├── auth_github_controller.ex
│ ├── github_webhook_controller.ex
│ ├── linear_webhook_controller.ex
│ └── session_controller.ex
│ ├── endpoint.ex
│ ├── gettext.ex
│ ├── html
│ ├── auth_github_app_html.ex
│ ├── auth_github_html.ex
│ ├── component_html.ex
│ ├── error_helpers.ex
│ ├── error_html.ex
│ └── session_html.ex
│ ├── live
│ ├── dashboard_live.ex
│ ├── dashboard_live.html.heex
│ ├── edit_issue_sync_live.ex
│ ├── edit_issue_sync_live.html.heex
│ ├── link_github_live.ex
│ ├── link_github_live.html.heex
│ ├── new_issue_sync_live.ex
│ ├── new_issue_sync_live.html.heex
│ ├── webhooks_live.ex
│ └── webhooks_live.html.heex
│ ├── router.ex
│ ├── telemetry.ex
│ └── templates
│ ├── auth_github
│ └── done.html.heex
│ ├── auth_github_app
│ └── pre_auth.html.heex
│ ├── component
│ └── select_box.html.heex
│ └── session
│ └── index.html.heex
├── mix.exs
├── mix.lock
├── priv
├── gettext
│ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ └── errors.pot
└── repo
│ ├── migrations
│ ├── .formatter.exs
│ ├── 20200708033846_create_accounts.exs
│ ├── 20200710002447_create_public_entries.exs
│ ├── 20200714142136_create_ln_issues.exs
│ ├── 20200716020450_alter_accounts_add_github_fields.exs
│ ├── 20200716135049_create_issue_syncs.exs
│ ├── 20200719165200_migrate_from_public_entries.exs
│ ├── 20200719172140_create_ln_comments.exs
│ ├── 20200719190206_alter_issue_syncs_update_options.exs
│ ├── 20200804203437_add_close_on_migrate.exs
│ ├── 20210613192941_create_github_webhooks.exs
│ ├── 20210613193228_create_linear_webhooks.exs
│ ├── 20210613205658_alter_issue_syncs_add_internal_webhooks.exs
│ ├── 20210614043950_alter_accounts_add_organization.exs
│ ├── 20210621144608_alter_ln_issues_add_gh_issue_number.exs
│ ├── 20210628231748_alter_ln_data_remove_text_fields.exs
│ ├── 20210719214602_alter_issue_syncs_add_sync_options.exs
│ ├── 20211016165055_alter_accounts_support_github_apps.exs
│ └── 20211016230631_create_shared_issues_shared_comments.exs
│ └── seeds.exs
├── rel
└── overlays
│ └── bin
│ ├── migrate
│ ├── migrate.bat
│ ├── server
│ └── server.bat
└── test
├── linear
├── accounts_test.exs
├── actions_test.exs
└── synchronize_test.exs
├── linear_web
└── html
│ └── error_html_test.exs
├── support
├── channel_case.ex
├── conn_case.ex
├── data_case.ex
└── factory.ex
└── test_helper.exs
/.dockerignore:
--------------------------------------------------------------------------------
1 | # This file excludes paths from the Docker build context.
2 | #
3 | # By default, Docker's build context includes all files (and folders) in the
4 | # current directory. Even if a file isn't copied into the container it is still sent to
5 | # the Docker daemon.
6 | #
7 | # There are multiple reasons to exclude files from the build context:
8 | #
9 | # 1. Prevent nested folders from being copied into the container (ex: exclude
10 | # /assets/node_modules when copying /assets)
11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
12 | # 3. Avoid sending files containing sensitive information
13 | #
14 | # More information on using .dockerignore is available here:
15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file
16 |
17 | .dockerignore
18 |
19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed:
20 | #
21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc
23 | .git
24 | !.git/HEAD
25 | !.git/refs
26 |
27 | # Common development/test artifacts
28 | /cover/
29 | /doc/
30 | /test/
31 | /tmp/
32 | .elixir_ls
33 |
34 | # Mix artifacts
35 | /_build/
36 | /deps/
37 | *.ez
38 |
39 | # Generated on crash by the VM
40 | erl_crash.dump
41 |
42 | # Static artifacts - These should be fetched and built inside the Docker image
43 | /assets/node_modules/
44 | /priv/static/assets/
45 | /priv/static/cache_manifest.json
46 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :ecto_sql, :phoenix],
3 | subdirectories: ["priv/*/migrations"],
4 | plugins: [Phoenix.LiveView.HTMLFormatter],
5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
6 | ]
7 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [jtormey]
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | linear-*.tar
24 |
25 | # If NPM crashes, it generates a log, let's ignore it too.
26 | npm-debug.log
27 |
28 | # The directory NPM downloads your dependencies sources to.
29 | /assets/node_modules/
30 |
31 | # Since we are building assets from assets/,
32 | # we ignore priv/static. You may want to comment
33 | # this depending on your deployment strategy.
34 | /priv/static/
35 |
36 | /config/*.secret.exs
37 |
38 | /.elixir_ls/
39 |
40 | /secret/
41 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
2 | # Alpine to avoid DNS resolution issues in production.
3 | #
4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
5 | # https://hub.docker.com/_/ubuntu?tab=tags
6 | #
7 | #
8 | # This file is based on these images:
9 | #
10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20220801-slim - for the release image
12 | # - https://pkgs.org/ - resource for finding needed packages
13 | # - Ex: hexpm/elixir:1.14.0-erlang-25.1-debian-bullseye-20220801-slim
14 | #
15 | ARG ELIXIR_VERSION=1.14.0
16 | ARG OTP_VERSION=25.1
17 | ARG DEBIAN_VERSION=bullseye-20220801-slim
18 |
19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
21 |
22 | FROM ${BUILDER_IMAGE} as builder
23 |
24 | # install build dependencies
25 | RUN apt-get update -y && apt-get install -y build-essential nodejs npm git \
26 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
27 |
28 | # prepare build dir
29 | WORKDIR /app
30 |
31 | # install hex + rebar
32 | RUN mix local.hex --force && \
33 | mix local.rebar --force
34 |
35 | # set build ENV
36 | ENV MIX_ENV="prod"
37 |
38 | # install mix dependencies
39 | COPY mix.exs mix.lock ./
40 | RUN mix deps.get --only $MIX_ENV
41 | RUN mkdir config
42 |
43 | # copy compile-time config files before we compile dependencies
44 | # to ensure any relevant config change will trigger the dependencies
45 | # to be re-compiled.
46 | COPY config/config.exs config/${MIX_ENV}.exs config/
47 | RUN mix deps.compile
48 |
49 | RUN npm cache verify
50 |
51 | COPY assets/package.json assets/package-lock.json ./assets/
52 | RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
53 |
54 | COPY priv priv
55 |
56 | COPY lib lib
57 |
58 | COPY assets assets
59 |
60 | # compile assets
61 | RUN mix assets.deploy
62 |
63 | # Compile the release
64 | RUN mix compile
65 |
66 | # Changes to config/releases.exs don't require recompiling the code
67 | COPY config/releases.exs config/
68 |
69 | COPY rel rel
70 | RUN mix release
71 |
72 | # start a new build stage so that the final image will only contain
73 | # the compiled release and other runtime necessities
74 | FROM ${RUNNER_IMAGE}
75 |
76 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
77 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
78 |
79 | # Set the locale
80 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
81 |
82 | ENV LANG en_US.UTF-8
83 | ENV LANGUAGE en_US:en
84 | ENV LC_ALL en_US.UTF-8
85 |
86 | WORKDIR "/app"
87 | RUN chown nobody /app
88 |
89 | # set runner ENV
90 | ENV MIX_ENV="prod"
91 |
92 | # Only copy the final release from the build stage
93 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/linear ./
94 |
95 | USER nobody
96 |
97 | CMD ["/app/bin/server"]
98 |
99 | # Appended by flyctl
100 | ENV ECTO_IPV6 true
101 | ENV ERL_AFLAGS "-proto_dist inet6_tcp"
102 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # LinearSync
2 |
3 | Syncs GitHub issues and comments with linear.app
4 |
5 | The following data are synced:
6 |
7 | | From | To | Notes |
8 | | ---- | -- | ----- |
9 | | **GitHub** issue open | **Linear** new issue with `[Open Status]` | Includes author, title, and description at creation. Select an `[Open Status]` on the web UI, and optionally a default assignee and label. |
10 | | **GitHub** issue close | **Linear** issue status set to `[Closed Status]` | Select a `[Closed Status]` on the web UI |
11 | | **GitHub** label | **Linear** label | A label with the same name must exist on Linear |
12 | | **GitHub** comment | **Linear** comment | |
13 | | **Linear** comment | **GitHub** comment | |
14 |
15 | ## Development
16 |
17 | Environment requirements:
18 |
19 | * [Elixir](https://elixir-lang.org/)
20 | * [Postgres](https://www.postgresql.org/)
21 | * [ngrok](https://ngrok.com/)
22 |
23 | ---
24 |
25 | Start local postgres service
26 |
27 | ```
28 | $ docker-compose up
29 | ```
30 |
31 | Setting up the application:
32 |
33 | * Start an ngrok session: `ngrok http 4000`
34 | * Copy `config/dev.secret.template.exs` to `config/dev.secret.exs`
35 | * Configure `ngrok_host` in `config/dev.secret.exs`
36 | * Configure `Linear.Repo` in `config/dev.secret.exs`
37 | * Configure `Linear.Auth.Github` in `config/dev.secret.exs` (see: [Creating a GitHub App](https://docs.github.com/en/developers/apps/creating-a-github-app)) with `repo` scope
38 | * Setup the project with `mix setup`
39 | * Start Phoenix endpoint with `iex -S mix phx.server`
40 |
41 | Now you can visit the URL provided by `ngrok` from your browser to access the application.
42 |
43 | ### Why use ngrok?
44 |
45 | Webhook requests from GitHub or Linear cannot target `localhost`, so while
46 | in development we use `ngrok` to expose the application to the internet. Phoenix
47 | uses this URL when configuring webhooks, therefore allowing the application to
48 | receive webhook requests while still running locally.
49 |
50 | ## Learn more
51 |
52 | * Official website: https://www.phoenixframework.org/
53 | * Guides: https://hexdocs.pm/phoenix/overview.html
54 | * Docs: https://hexdocs.pm/phoenix
55 | * Forum: https://elixirforum.com/c/phoenix-forum
56 | * Source: https://github.com/phoenixframework/phoenix
57 |
--------------------------------------------------------------------------------
/assets/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/assets/css/app.scss:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | background: rgb(31, 32, 35);
7 | overflow: hidden;
8 | }
9 |
10 | .el-input {
11 | @apply w-full p-4 bg-dark-100 text-dark-600 text-xs border border-dark-300 rounded outline-none;
12 | }
13 |
14 | .el-button {
15 | @apply w-full p-4 bg-dark-200 text-dark-600 text-xs border border-dark-300 rounded outline-none;
16 | }
17 |
18 | .el-button-slim {
19 | @apply w-full px-4 py-2 bg-dark-200 text-dark-600 text-xs border border-dark-300 rounded outline-none;
20 | }
21 |
22 | /* LiveView specific classes for your customizations */
23 | .invalid-feedback {
24 | @apply text-sm text-right;
25 | color: #a94442;
26 | display: block;
27 | }
28 |
29 | .phx-no-feedback.invalid-feedback, .phx-no-feedback .invalid-feedback {
30 | display: none;
31 | }
32 |
33 | .phx-click-loading {
34 | opacity: 0.5;
35 | transition: opacity 1s ease-out;
36 | }
37 |
38 | .phx-disconnected{
39 | cursor: wait;
40 | }
41 | .phx-disconnected *{
42 | pointer-events: none;
43 | }
44 |
45 | .phx-modal {
46 | opacity: 1!important;
47 | position: fixed;
48 | z-index: 1;
49 | left: 0;
50 | top: 0;
51 | width: 100%;
52 | height: 100%;
53 | overflow: auto;
54 | background-color: rgb(0,0,0);
55 | background-color: rgba(0,0,0,0.4);
56 | }
57 |
58 | .phx-modal-content {
59 | background-color: #fefefe;
60 | margin: 15% auto;
61 | padding: 20px;
62 | border: 1px solid #888;
63 | width: 80%;
64 | }
65 |
66 | .phx-modal-close {
67 | color: #aaa;
68 | float: right;
69 | font-size: 28px;
70 | font-weight: bold;
71 | }
72 |
73 | .phx-modal-close:hover,
74 | .phx-modal-close:focus {
75 | color: black;
76 | text-decoration: none;
77 | cursor: pointer;
78 | }
79 |
80 | /* Alerts and form errors */
81 | .alert-container {
82 | @apply z-10;
83 | @apply fixed bottom-0 left-2 right-2 md:bottom-4 md:left-16 md:right-16;
84 | @apply flex flex-col justify-center;
85 | }
86 |
87 | .alert {
88 | @apply mx-auto shadow-lg rounded-md py-2 px-4 mb-4 text-sm;
89 | }
90 |
91 | .alert-info {
92 | @apply bg-blue-500 text-white;
93 | }
94 |
95 | .alert-error {
96 | @apply bg-red-500 text-white;
97 | }
98 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | import '../css/app.scss'
2 |
3 | import 'phoenix_html'
4 | import { Socket } from 'phoenix'
5 | import topbar from 'topbar'
6 | import { LiveSocket } from 'phoenix_live_view'
7 |
8 | const hooks = {}
9 | hooks['open-window'] = require('@phx-hook/open-window')()
10 |
11 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
12 | let liveSocket = new LiveSocket('/live', Socket, { hooks, params: { _csrf_token: csrfToken } })
13 |
14 | // Show progress bar on live navigation and form submits
15 | topbar.config({ barColors: { 0: '#6876f5' }, shadowColor: 'rgba(0, 0, 0, .3)' })
16 | window.addEventListener('phx:page-loading-start', info => topbar.show())
17 | window.addEventListener('phx:page-loading-stop', info => topbar.hide())
18 |
19 | // connect if there are any LiveViews on the page
20 | liveSocket.connect()
21 |
22 | // expose liveSocket on window for web console debug logs and latency simulation:
23 | // >> liveSocket.enableDebug()
24 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
25 | // >> liveSocket.enableLatencySim(1000)
26 | window.liveSocket = liveSocket
27 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "repository": {},
3 | "description": " ",
4 | "license": "MIT",
5 | "scripts": {
6 | "deploy": "webpack --mode production",
7 | "watch": "webpack --mode development --watch"
8 | },
9 | "dependencies": {
10 | "@phx-hook/open-window": "^0.1.0",
11 | "@tailwindcss/forms": "^0.3.3",
12 | "@tailwindcss/ui": "^0.7.2",
13 | "phoenix": "file:../deps/phoenix",
14 | "phoenix_html": "file:../deps/phoenix_html",
15 | "phoenix_live_view": "file:../deps/phoenix_live_view",
16 | "tailwindcss": "^2.1.4",
17 | "topbar": "^0.1.4"
18 | },
19 | "devDependencies": {
20 | "@babel/core": "^7.0.0",
21 | "@babel/preset-env": "^7.0.0",
22 | "autoprefixer": "^10.2.6",
23 | "babel-loader": "^8.0.0",
24 | "copy-webpack-plugin": "^5.1.1",
25 | "css-loader": "^3.4.2",
26 | "mini-css-extract-plugin": "^0.9.0",
27 | "optimize-css-assets-webpack-plugin": "^5.0.1",
28 | "postcss": "^8.2.9",
29 | "postcss-import": "^14.0.2",
30 | "postcss-loader": "^4.3.0",
31 | "postcss-nested": "^5.0.5",
32 | "sass-loader": "^8.0.2",
33 | "terser-webpack-plugin": "^2.3.2",
34 | "webpack": "4.41.5",
35 | "webpack-cli": "^3.3.2"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/assets/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('postcss-import'),
4 | require('postcss-nested'),
5 | require('tailwindcss'),
6 | require('autoprefixer')
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/assets/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtormey/linear-sync/070a43bd8d94c486eae3060ebb477ca5c8fd93d1/assets/static/favicon.ico
--------------------------------------------------------------------------------
/assets/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const plugin = require("tailwindcss/plugin")
2 |
3 | module.exports = {
4 | content: [
5 | "./js/**/*.js",
6 | "../lib/*_web.ex",
7 | "../lib/*_web/**/*.*ex"
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | brand: "#FD4F00",
13 | 'dark': {
14 | '100': 'rgb(31, 32, 35)',
15 | '150': 'rgb(39, 40, 43)',
16 | '200': 'rgb(48, 50, 54)',
17 | '300': 'rgb(60, 63, 68)',
18 | '500': 'rgb(138, 143, 152)',
19 | '600': 'rgb(215, 216, 219)'
20 | }
21 | }
22 | },
23 | },
24 | plugins: [
25 | require('@tailwindcss/ui'),
26 | require("@tailwindcss/forms"),
27 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
28 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
29 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
30 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]))
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/assets/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
3 | const TerserPlugin = require('terser-webpack-plugin')
4 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
5 | const CopyWebpackPlugin = require('copy-webpack-plugin')
6 |
7 | module.exports = (env, options) => {
8 | const devMode = options.mode !== 'production'
9 |
10 | return {
11 | optimization: {
12 | minimizer: [
13 | new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }),
14 | new OptimizeCSSAssetsPlugin({})
15 | ]
16 | },
17 | entry: {
18 | 'app': './js/app.js'
19 | },
20 | output: {
21 | filename: '[name].js',
22 | path: path.resolve(__dirname, '../priv/static/js'),
23 | publicPath: '/js/'
24 | },
25 | devtool: devMode ? 'eval-cheap-module-source-map' : undefined,
26 | module: {
27 | rules: [
28 | {
29 | test: /\.js$/,
30 | exclude: /node_modules/,
31 | use: {
32 | loader: 'babel-loader'
33 | }
34 | },
35 | {
36 | test: /\.[s]?css$/,
37 | use: [
38 | MiniCssExtractPlugin.loader,
39 | 'css-loader',
40 | 'postcss-loader'
41 | ]
42 | }
43 | ]
44 | },
45 | plugins: [
46 | new MiniCssExtractPlugin({ filename: '../css/app.css' }),
47 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }])
48 | ]
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :linear,
11 | ecto_repos: [Linear.Repo]
12 |
13 | config :linear, :generators, binary_id: true
14 |
15 | config :linear, Linear.Repo,
16 | migration_primary_key: [name: :id, type: :binary_id],
17 | migration_timestamps: [type: :utc_datetime]
18 |
19 | # Configures the endpoint
20 | config :linear, LinearWeb.Endpoint,
21 | url: [host: "localhost"],
22 | secret_key_base: "xCQwaiDYqj3vZmL55d8+oVDk21+6kbx9mHKgXb5wl370QEZ1/ukPmwGBeX5BnyPR",
23 | render_errors: [view: LinearWeb.ErrorHTML, accepts: ~w(html json), layout: false],
24 | pubsub_server: Linear.PubSub,
25 | live_view: [signing_salt: "1jIiBu4B"]
26 |
27 | # Configures Elixir's Logger
28 | config :logger, :console,
29 | format: "$time $metadata[$level] $message\n",
30 | metadata: [:request_id]
31 |
32 | # Use Jason for JSON parsing in Phoenix
33 | config :phoenix, :json_library, Jason
34 |
35 | # Import environment specific config. This must remain at the bottom
36 | # of this file so it overrides the configuration defined above.
37 | import_config "#{Mix.env()}.exs"
38 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | System.put_env("NODE_ENV", "development")
4 |
5 | # For development, we disable any cache and enable
6 | # debugging and code reloading.
7 | #
8 | # The watchers configuration can be used to run external
9 | # watchers to your application. For example, we use it
10 | # with webpack to recompile .js and .css sources.
11 | config :linear, LinearWeb.Endpoint,
12 | http: [port: 4000],
13 | debug_errors: true,
14 | code_reloader: true,
15 | check_origin: false,
16 | watchers: [
17 | node: [
18 | "node_modules/webpack/bin/webpack.js",
19 | "--mode",
20 | "development",
21 | "--watch-stdin",
22 | "--color",
23 | cd: Path.expand("../assets", __DIR__)
24 | ]
25 | ]
26 |
27 | # ## SSL Support
28 | #
29 | # In order to use HTTPS in development, a self-signed
30 | # certificate can be generated by running the following
31 | # Mix task:
32 | #
33 | # mix phx.gen.cert
34 | #
35 | # Note that this task requires Erlang/OTP 20 or later.
36 | # Run `mix help phx.gen.cert` for more information.
37 | #
38 | # The `http:` config above can be replaced with:
39 | #
40 | # https: [
41 | # port: 4001,
42 | # cipher_suite: :strong,
43 | # keyfile: "priv/cert/selfsigned_key.pem",
44 | # certfile: "priv/cert/selfsigned.pem"
45 | # ],
46 | #
47 | # If desired, both `http:` and `https:` keys can be
48 | # configured to run both http and https servers on
49 | # different ports.
50 |
51 | # Watch static and templates for browser reloading.
52 | config :linear, LinearWeb.Endpoint,
53 | live_reload: [
54 | patterns: [
55 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
56 | ~r"priv/gettext/.*(po)$",
57 | ~r"lib/linear_web/(live|views)/.*(ex)$",
58 | ~r"lib/linear_web/templates/.*(eex)$"
59 | ]
60 | ]
61 |
62 | # Do not include metadata nor timestamps in development logs
63 | config :logger, :console, format: "[$level] $message\n"
64 |
65 | # Set a higher stacktrace during development. Avoid configuring such
66 | # in production as building large stacktraces may be expensive.
67 | config :phoenix, :stacktrace_depth, 20
68 |
69 | # Initialize plugs at runtime for faster development compilation
70 | config :phoenix, :plug_init_mode, :runtime
71 |
72 | import_config "dev.secret.exs"
73 |
--------------------------------------------------------------------------------
/config/dev.secret.template.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | ngrok_host = "000000000000.ngrok.io"
4 |
5 | config :linear, Linear.Repo,
6 | username: "postgres",
7 | password: "postgres",
8 | database: "linear_dev",
9 | hostname: "localhost",
10 | show_sensitive_data_on_connection_error: true,
11 | pool_size: 10
12 |
13 | config :linear, LinearWeb.Endpoint, url: [scheme: "https", host: ngrok_host, port: 443]
14 |
15 | config :oauth2, debug: true
16 |
17 | config :linear, Linear.Auth.Github,
18 | client_id: "GITHUB_CLIENT_ID",
19 | client_secret: "GITHUB_CLIENT_SECRET",
20 | redirect_uri: "https://#{ngrok_host}/auth/github/callback",
21 | scope: "repo"
22 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For production, don't forget to configure the url host
4 | # to something meaningful, Phoenix uses this information
5 | # when generating URLs.
6 | #
7 | # Note we also include the path to a cache manifest
8 | # containing the digested version of static files. This
9 | # manifest is generated by the `mix phx.digest` task,
10 | # which you should run after static files are built and
11 | # before starting your production server.
12 | config :linear, LinearWeb.Endpoint,
13 | url: [scheme: "https", host: "linear-sync.com", port: 443],
14 | cache_static_manifest: "priv/static/cache_manifest.json",
15 | server: true
16 |
17 | # Do not print debug messages in production
18 | config :logger, level: :info
19 |
20 | # ## SSL Support
21 | #
22 | # To get SSL working, you will need to add the `https` key
23 | # to the previous section and set your `:url` port to 443:
24 | #
25 | # config :linear, LinearWeb.Endpoint,
26 | # ...
27 | # url: [host: "example.com", port: 443],
28 | # https: [
29 | # port: 443,
30 | # cipher_suite: :strong,
31 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
32 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH"),
33 | # transport_options: [socket_opts: [:inet6]]
34 | # ]
35 | #
36 | # The `cipher_suite` is set to `:strong` to support only the
37 | # latest and more secure SSL ciphers. This means old browsers
38 | # and clients may not be supported. You can set it to
39 | # `:compatible` for wider support.
40 | #
41 | # `:keyfile` and `:certfile` expect an absolute path to the key
42 | # and cert in disk or a relative path inside priv, for example
43 | # "priv/ssl/server.key". For all supported SSL configuration
44 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
45 | #
46 | # We also recommend setting `force_ssl` in your endpoint, ensuring
47 | # no data is ever sent via http, always redirecting to https:
48 | #
49 | # config :linear, LinearWeb.Endpoint,
50 | # force_ssl: [hsts: true]
51 | #
52 | # Check `Plug.SSL` for all available options in `force_ssl`.
53 |
--------------------------------------------------------------------------------
/config/releases.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | config :linear, Linear.Repo,
4 | url: System.fetch_env!("DATABASE_URL"),
5 | socket_options: [:inet6],
6 | pool_size: 10
7 |
8 | config :linear, LinearWeb.Endpoint,
9 | http: [port: String.to_integer(System.fetch_env!("PORT"))],
10 | secret_key_base: System.fetch_env!("SECRET_KEY_BASE"),
11 | server: true
12 |
13 | config :linear, Linear.Auth.GithubApp,
14 | app_id: System.fetch_env!("GITHUB_APP_ID"),
15 | app_name: System.fetch_env!("GITHUB_APP_NAME"),
16 | client_id: System.fetch_env!("GITHUB_APP_CLIENT_ID"),
17 | client_secret: System.fetch_env!("GITHUB_APP_CLIENT_SECRET"),
18 | redirect_uri: System.fetch_env!("GITHUB_APP_REDIRECT_URI")
19 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | #
5 | # The MIX_TEST_PARTITION environment variable can be used
6 | # to provide built-in test partitioning in CI environment.
7 | # Run `mix help test` for more information.
8 | config :linear, Linear.Repo,
9 | username: "postgres",
10 | password: "postgres",
11 | database: "linear_test#{System.get_env("MIX_TEST_PARTITION")}",
12 | hostname: "localhost",
13 | pool: Ecto.Adapters.SQL.Sandbox
14 |
15 | # We don't run a server during test. If one is required,
16 | # you can enable the server option below.
17 | config :linear, LinearWeb.Endpoint,
18 | http: [port: 4002],
19 | server: false
20 |
21 | config :linear, :linear_api, Linear.LinearAPIMock
22 | config :linear, :github_api, Linear.GithubAPIMock
23 |
24 | # Print only warnings and errors during test
25 | config :logger, level: :warn
26 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.1'
2 |
3 | services:
4 | db:
5 | image: postgres:13.2
6 | restart: unless-stopped
7 | volumes:
8 | - linear-db:/var/lib/postgresql/data
9 | environment:
10 | POSTGRES_USER: postgres
11 | POSTGRES_PASSWORD: postgres
12 | POSTGRES_DB: linear_dev
13 | ports:
14 | - 5432:5432
15 | volumes:
16 | linear-db:
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml file generated for linear-sync on 2022-10-25T10:07:04-05:00
2 |
3 | app = "linear-sync"
4 |
5 | kill_signal = "SIGTERM"
6 | kill_timeout = 5
7 | processes = []
8 |
9 | [deploy]
10 | release_command = "/app/bin/migrate"
11 |
12 | [env]
13 | PHX_HOST = "linear-sync.fly.dev"
14 | PORT = "8080"
15 |
16 | [experimental]
17 | allowed_public_ports = []
18 | auto_rollback = true
19 |
20 | [[services]]
21 | http_checks = []
22 | internal_port = 8080
23 | processes = ["app"]
24 | protocol = "tcp"
25 | script_checks = []
26 |
27 | [services.concurrency]
28 | hard_limit = 25
29 | soft_limit = 20
30 | type = "connections"
31 |
32 | [[services.ports]]
33 | force_https = true
34 | handlers = ["http"]
35 | port = 80
36 |
37 | [[services.ports]]
38 | handlers = ["tls", "http"]
39 | port = 443
40 |
41 | [[services.tcp_checks]]
42 | grace_period = "1s"
43 | interval = "15s"
44 | restart_limit = 0
45 | timeout = "2s"
46 |
--------------------------------------------------------------------------------
/lib/linear.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear do
2 | @moduledoc """
3 | Linear keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/lib/linear/accounts.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Accounts do
2 | @moduledoc """
3 | The Accounts context.
4 | """
5 |
6 | import Ecto.Query, warn: false
7 |
8 | alias Linear.Repo
9 | alias Linear.LinearAPI
10 | alias Linear.Accounts.Account
11 | alias Linear.Data
12 | alias Linear.IssueSyncService
13 |
14 | @doc """
15 | Gets a single account.
16 |
17 | Raises `Ecto.NoResultsError` if the Account does not exist.
18 | """
19 | def get_account!(id), do: Repo.get!(Account, id)
20 |
21 | @doc """
22 | Gets a single account by a set of options.
23 | """
24 | def get_account_by(opts), do: Repo.get_by(Account, opts)
25 |
26 | @doc """
27 | Finds an account by api_key.
28 |
29 | If the account is not found, tries to load the organization from the
30 | Linear API. If successful, tries to find an existing organization in the database.
31 | If one is found, updates the API key, otherwise creates a new one.
32 | """
33 | def find_or_create_account(api_key) when is_binary(api_key) do
34 | session = LinearAPI.Session.new(api_key)
35 |
36 | linear_api = Application.get_env(:linear, :linear_api, LinearAPI)
37 |
38 | with nil <- Repo.get_by(Account, api_key: api_key),
39 | {:ok, %{"data" => %{"organization" => %{"id" => org_id}}}} <-
40 | linear_api.organization(session) do
41 | if account = Repo.get_by(Account, organization_id: org_id) do
42 | account =
43 | account
44 | |> Account.changeset(%{api_key: api_key})
45 | |> Repo.update!()
46 |
47 | {:replaced, account}
48 | else
49 | %Account{}
50 | |> Account.changeset(%{api_key: api_key})
51 | |> Ecto.Changeset.put_change(:organization_id, org_id)
52 | |> Repo.insert()
53 | end
54 | else
55 | %Account{} = account ->
56 | {:ok, account}
57 |
58 | {:ok, %{"data" => nil}} ->
59 | {:error, :invalid_api_key}
60 | end
61 | end
62 |
63 | @doc """
64 | Updates the github connection details for an account.
65 | """
66 | def update_account_github_link(%Account{} = account, attrs) do
67 | account
68 | |> Ecto.Changeset.cast(attrs, [:github_token, :github_link_state, :github_installation_id])
69 | |> Repo.update()
70 | |> broadcast(:github_link)
71 | end
72 |
73 | @doc """
74 | Deletes the github connection details for an account.
75 | """
76 | def delete_account_github_link(%Account{} = account) do
77 | account
78 | |> Ecto.Changeset.change(
79 | github_token: nil,
80 | github_link_state: nil,
81 | github_installation_id: nil
82 | )
83 | |> Repo.update()
84 | |> broadcast(:github_link)
85 | end
86 |
87 | @doc """
88 | Deletes an account.
89 | """
90 | def delete_account(%Account{} = account) do
91 | with :ok <- IssueSyncService.disable_issue_syncs_for_account(account),
92 | :ok <- Data.delete_disabled_issue_syncs_for_account(account) do
93 | Repo.delete(account)
94 | end
95 | end
96 |
97 | @doc """
98 | Returns an `%Ecto.Changeset{}` for tracking account changes.
99 | """
100 | def change_account(%Account{} = account, attrs \\ %{}) do
101 | Account.changeset(account, attrs)
102 | end
103 |
104 | def subscribe(account = %Account{}) do
105 | Phoenix.PubSub.subscribe(Linear.PubSub, "account:#{account.id}")
106 | end
107 |
108 | def broadcast({:ok, account = %Account{}}, type) do
109 | Phoenix.PubSub.broadcast(Linear.PubSub, "account:#{account.id}", {type, account})
110 | {:ok, account}
111 | end
112 |
113 | def broadcast({:error, _} = error, _type), do: error
114 | end
115 |
--------------------------------------------------------------------------------
/lib/linear/accounts/account.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Accounts.Account do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Linear.Data.IssueSync
6 |
7 | @primary_key {:id, :binary_id, autogenerate: true}
8 | @foreign_key_type :binary_id
9 |
10 | schema "accounts" do
11 | field :api_key, :string
12 | field :organization_id, Ecto.UUID
13 | field :github_token, :string
14 | field :github_link_state, :string
15 | field :github_installation_id, :string
16 |
17 | has_many :issue_syncs, IssueSync
18 |
19 | timestamps()
20 | end
21 |
22 | @doc false
23 | def changeset(account, attrs) do
24 | account
25 | |> cast(attrs, [:api_key])
26 | |> validate_required([:api_key])
27 | |> unique_constraint(:api_key, name: :accounts_api_key_index)
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/linear/actions.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions do
2 | require Logger
3 |
4 | alias __MODULE__
5 | alias Linear.Synchronize.Event
6 | alias Linear.Synchronize.ContentWriter
7 |
8 | @doc """
9 | """
10 | def for_event(%Event{source: :github, action: :opened_issue} = event, context) do
11 | handle_existing_linear_issue =
12 | if context.issue_sync.sync_github_issue_titles do
13 | fn issue_key ->
14 | [
15 | Actions.FetchLinearIssue.new(%{
16 | issue_key: issue_key,
17 | replace_shared_issue: true
18 | }),
19 | Actions.CreateLinearComment.new(%{
20 | body: ContentWriter.linear_comment_issue_linked_body(event.data.github_issue)
21 | })
22 | ]
23 | end
24 | else
25 | fn _issue_key -> [] end
26 | end
27 |
28 | case ContentWriter.parse_linear_issue_keys(event.data.github_issue.title) do
29 | [] ->
30 | create_linear_issue? =
31 | context.shared_issue.linear_issue_id == nil and
32 | not ContentWriter.via_linear_sync?(event.data.github_issue.body)
33 |
34 | if create_linear_issue? do
35 | Actions.CreateLinearIssue.new(%{
36 | title:
37 | ContentWriter.linear_issue_title(event.data.github_repo, event.data.github_issue),
38 | body: ContentWriter.linear_issue_body(event.data.github_repo, event.data.github_issue)
39 | })
40 | end
41 |
42 | [issue_key] ->
43 | handle_existing_linear_issue.(issue_key)
44 |
45 | [issue_key | _rest] ->
46 | Logger.warn(
47 | "Multiple linear issue keys per github issue not supported, using the first found"
48 | )
49 |
50 | handle_existing_linear_issue.(issue_key)
51 | end
52 | end
53 |
54 | def for_event(%Event{source: :github, action: :reopened_issue}, context) do
55 | Actions.UpdateLinearIssue.new(%{
56 | state_id: context.issue_sync.open_state_id
57 | })
58 | end
59 |
60 | def for_event(%Event{source: :github, action: :closed_issue}, context) do
61 | if not context.issue_sync.close_on_open do
62 | Actions.UpdateLinearIssue.new(%{
63 | state_id: context.issue_sync.close_state_id
64 | })
65 | end
66 | end
67 |
68 | def for_event(%Event{source: :github, action: :created_comment} = event, _context) do
69 | create_linear_comment? = not ContentWriter.via_linear_sync?(event.data.github_comment.body)
70 |
71 | if create_linear_comment? do
72 | Actions.CreateLinearComment.new(%{
73 | body: ContentWriter.linear_comment_body(event.data.github_comment)
74 | })
75 | end
76 | end
77 |
78 | def for_event(%Event{source: :github, action: :labeled_issue} = event, _context) do
79 | [
80 | Actions.FetchLinearLabels.new(),
81 | Actions.FetchLinearIssue.new(),
82 | Actions.UpdateLinearIssue.new(%{
83 | add_labels: [event.data.github_label]
84 | })
85 | ]
86 | end
87 |
88 | def for_event(%Event{source: :github, action: :unlabeled_issue} = event, _context) do
89 | [
90 | Actions.FetchLinearLabels.new(),
91 | Actions.FetchLinearIssue.new(),
92 | Actions.UpdateLinearIssue.new(%{
93 | remove_labels: [event.data.github_label]
94 | })
95 | ]
96 | end
97 |
98 | def for_event(%Event{source: :linear, action: :created_issue} = event, context) do
99 | create_github_issue? =
100 | context.shared_issue.github_issue_id == nil and
101 | not ContentWriter.via_linear_sync?(event.data.linear_issue.description)
102 |
103 | if context.issue_sync.sync_linear_to_github and create_github_issue? do
104 | github_issue_title =
105 | if context.issue_sync.sync_github_issue_titles do
106 | ContentWriter.github_issue_title_from_linear(event.data.linear_issue)
107 | else
108 | event.data.linear_issue.title
109 | end
110 |
111 | [
112 | Actions.BlockLinearPrivateIssue.new(),
113 | Actions.CreateGithubIssue.new(%{
114 | title: github_issue_title,
115 | body: ContentWriter.github_issue_body(event.data.linear_issue)
116 | })
117 | ]
118 | end
119 | end
120 |
121 | def for_event(%Event{source: :linear, action: :updated_issue} = event, context) do
122 | if context.issue_sync.sync_linear_to_github do
123 | [
124 | with %{open_state_id: state_id} <- context.issue_sync,
125 | %{added_state_id: ^state_id} <- event.data.linear_state_diff do
126 | Actions.UpdateGithubIssue.new(%{
127 | state: :opened
128 | })
129 | else
130 | _otherwise -> nil
131 | end,
132 | with %{close_state_id: state_id} <- context.issue_sync,
133 | %{added_state_id: ^state_id} <- event.data.linear_state_diff do
134 | Actions.UpdateGithubIssue.new(%{
135 | state: :closed
136 | })
137 | else
138 | _otherwise -> nil
139 | end,
140 | if event.data.linear_labels_diff do
141 | [
142 | Actions.FetchLinearLabels.new(),
143 | Actions.FetchGithubLabels.new(),
144 | Actions.AddGithubLabels.new(%{
145 | label_ids: event.data.linear_labels_diff.added_label_ids
146 | }),
147 | Actions.RemoveGithubLabels.new(%{
148 | label_ids: event.data.linear_labels_diff.removed_label_ids
149 | })
150 | ]
151 | end
152 | ]
153 | end
154 | end
155 |
156 | def for_event(%Event{source: :linear, action: :created_comment} = event, context) do
157 | create_github_comment? =
158 | context.issue_sync.sync_linear_to_github and
159 | not ContentWriter.via_linear_sync?(event.data.linear_comment.body)
160 |
161 | if create_github_comment? do
162 | [
163 | Actions.BlockLinearPrivateIssue.new(),
164 | Actions.FetchLinearIssue.new(),
165 | Actions.CreateGithubComment.new(%{
166 | create_body: fn context ->
167 | ContentWriter.github_issue_comment_body(
168 | context.linear_issue,
169 | event.data.linear_comment.body
170 | )
171 | end
172 | })
173 | ]
174 | end
175 | end
176 | end
177 |
--------------------------------------------------------------------------------
/lib/linear/actions/add_github_labels.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.AddGithubLabels do
2 | alias Linear.Actions.Helpers
3 | alias Linear.GithubAPI.GithubData, as: Gh
4 |
5 | @enforce_keys [:label_ids]
6 | defstruct [:label_ids]
7 |
8 | def new(fields), do: struct(__MODULE__, fields)
9 |
10 | def requires?(dep), do: dep == :github
11 |
12 | def process(%__MODULE__{label_ids: []}, context), do: {:ok, context}
13 |
14 | def process(%__MODULE__{} = action, %{issue_sync: issue_sync} = context) do
15 | repo_labels = Map.fetch!(context, :github_repo_labels)
16 | issue_label_ids = Map.fetch!(context, :github_issue_labels) |> Enum.map(& &1.id)
17 | linear_labels = Map.fetch!(context, :linear_labels)
18 |
19 | repo_labels_to_add =
20 | Enum.filter(repo_labels, fn %Gh.Label{} = repo_label ->
21 | if ln_label =
22 | Enum.find(linear_labels, &Helpers.Labels.labels_match?(&1.name, repo_label.name)) do
23 | ln_label.id in action.label_ids and repo_label.id not in issue_label_ids
24 | end
25 | end)
26 |
27 | {client, repo_key} = Helpers.client_repo_key(issue_sync)
28 |
29 | Helpers.github_api().add_issue_labels(
30 | client,
31 | repo_key,
32 | context.shared_issue.github_issue_number,
33 | Enum.map(repo_labels_to_add, & &1.name)
34 | )
35 | |> case do
36 | {200, _body, _response} ->
37 | {:ok, context}
38 |
39 | _otherwise ->
40 | {:error, :add_github_labels}
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/linear/actions/block_linear_private_issue.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.BlockLinearPrivateIssue do
2 | alias Linear.Actions.Helpers
3 | alias Linear.LinearAPI
4 | alias Linear.LinearQuery
5 | alias Linear.LinearAPI.LinearData, as: Ln
6 |
7 | @enforce_keys []
8 | defstruct []
9 |
10 | @private_label "private"
11 |
12 | def new(fields \\ %{}), do: struct(__MODULE__, fields)
13 |
14 | def requires?(dep), do: dep == :linear
15 |
16 | def process(%__MODULE__{}, context) do
17 | case fetch_labels(context) do
18 | {:ok, labels} ->
19 | if has_private_label?(labels) do
20 | {:error, :linear_issue_is_private}
21 | else
22 | {:ok, context}
23 | end
24 |
25 | :error ->
26 | {:error, :block_linear_private_issue}
27 | end
28 | end
29 |
30 | defp fetch_labels(context) do
31 | with :error <- fetch_labels_from_context(context),
32 | :error <- fetch_labels_from_linear_query(context) do
33 | :error
34 | end
35 | end
36 |
37 | defp fetch_labels_from_context(context) do
38 | if labels = context.linear_issue.labels, do: {:ok, labels}, else: :error
39 | end
40 |
41 | defp fetch_labels_from_linear_query(%{issue_sync: issue_sync} = context) do
42 | session = LinearAPI.Session.new(issue_sync.account)
43 |
44 | with {:ok, labels} <- LinearQuery.list_issue_labels(session, context.linear_issue.id) do
45 | {:ok, Enum.map(labels, &Ln.Label.new/1)}
46 | end
47 | end
48 |
49 | defp has_private_label?(labels) do
50 | Enum.any?(labels, &Helpers.Labels.labels_match?(&1.name, @private_label))
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/linear/actions/create_github_comment.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.CreateGithubComment do
2 | require Logger
3 |
4 | alias Linear.Actions.Helpers
5 |
6 | @enforce_keys []
7 | defstruct [:body, :create_body]
8 |
9 | def new(fields), do: struct(__MODULE__, fields)
10 |
11 | def requires?(dep), do: dep == :github
12 |
13 | def process(%__MODULE__{} = action, %{issue_sync: issue_sync} = context) do
14 | {client, repo_key} = Helpers.client_repo_key(issue_sync)
15 |
16 | Helpers.github_api().create_issue_comment(
17 | client,
18 | repo_key,
19 | context.shared_issue.github_issue_number,
20 | action.body || action.create_body.(context)
21 | )
22 | |> case do
23 | {201, _body, _response} ->
24 | {:ok, context}
25 |
26 | error ->
27 | Logger.error("Error creating github issue comment: #{inspect(error)}")
28 |
29 | {:error, :create_github_comment}
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/linear/actions/create_github_issue.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.CreateGithubIssue do
2 | require Logger
3 |
4 | alias Linear.Actions.Helpers
5 | alias Linear.GithubAPI.GithubData, as: Gh
6 |
7 | @enforce_keys [:title, :body]
8 | defstruct [:title, :body]
9 |
10 | def new(fields), do: struct(__MODULE__, fields)
11 |
12 | def requires?(_any), do: false
13 |
14 | def process(%__MODULE__{} = action, %{issue_sync: issue_sync} = context) do
15 | {client, repo_key} = Helpers.client_repo_key(issue_sync)
16 |
17 | Helpers.github_api().create_issue(
18 | client,
19 | repo_key,
20 | %{
21 | "title" => action.title,
22 | "body" => action.body
23 | }
24 | )
25 | |> case do
26 | {201, github_issue_data, _response} ->
27 | github_issue = Gh.Issue.new(github_issue_data)
28 |
29 | context.shared_issue
30 | |> Helpers.update_shared_issue(github_issue)
31 | |> case do
32 | {:ok, shared_issue} ->
33 | context =
34 | context
35 | |> Map.put(:shared_issue, shared_issue)
36 | |> Map.put(:github_issue, github_issue)
37 |
38 | {:ok, context}
39 |
40 | {:error, reason} ->
41 | {:error, {:create_github_issue, reason}}
42 | end
43 |
44 | error ->
45 | Logger.error("Error creating github issue: #{inspect(error)}")
46 | {:error, :create_github_issue}
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/linear/actions/create_linear_comment.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.CreateLinearComment do
2 | alias Linear.LinearAPI
3 | alias Linear.LinearQuery
4 |
5 | @enforce_keys [:body]
6 | defstruct [:body]
7 |
8 | def new(fields), do: struct(__MODULE__, fields)
9 |
10 | def requires?(dep), do: dep == :linear
11 |
12 | def process(%__MODULE__{} = action, %{issue_sync: issue_sync} = context) do
13 | session = LinearAPI.Session.new(issue_sync.account)
14 |
15 | LinearQuery.create_issue_comment(
16 | session,
17 | context.shared_issue.linear_issue_id,
18 | body: action.body
19 | )
20 | |> case do
21 | {:ok, _linear_comment_data} ->
22 | {:ok, context}
23 |
24 | :error ->
25 | {:error, :create_linear_comment}
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/linear/actions/create_linear_issue.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.CreateLinearIssue do
2 | alias Linear.LinearAPI
3 | alias Linear.LinearAPI.LinearData, as: Ln
4 | alias Linear.LinearQuery
5 | alias Linear.Actions
6 | alias Linear.Actions.Helpers
7 | alias Linear.Synchronize.ContentWriter
8 | alias Linear.Util
9 |
10 | @enforce_keys [:title, :body]
11 | defstruct [:title, :body]
12 |
13 | def new(fields), do: struct(__MODULE__, fields)
14 |
15 | def requires?(_any), do: false
16 |
17 | def process(%__MODULE__{} = action, %{issue_sync: issue_sync} = context) do
18 | session = LinearAPI.Session.new(issue_sync.account)
19 |
20 | args = [
21 | teamId: issue_sync.team_id,
22 | title: action.title,
23 | description: action.body
24 | ]
25 |
26 | args =
27 | args
28 | |> Util.Control.put_non_nil(:stateId, issue_sync.open_state_id)
29 | |> Util.Control.put_non_nil(:labelIds, issue_sync.label_id, &List.wrap/1)
30 | |> Util.Control.put_non_nil(:assigneeId, issue_sync.assignee_id)
31 |
32 | case LinearQuery.create_issue(session, args) do
33 | {:ok, linear_issue_data} ->
34 | linear_issue = Ln.Issue.new(linear_issue_data)
35 |
36 | context.shared_issue
37 | |> Helpers.update_shared_issue(linear_issue)
38 | |> case do
39 | {:ok, shared_issue} ->
40 | context =
41 | context
42 | |> Map.put(:shared_issue, shared_issue)
43 | |> Map.put(:linear_issue, linear_issue)
44 |
45 | {:cont, {context, next_actions(context)}}
46 |
47 | {:error, reason} ->
48 | {:error, {:create_linear_issue, reason}}
49 | end
50 |
51 | :error ->
52 | {:error, :create_linear_issue}
53 | end
54 | end
55 |
56 | defp next_actions(%{issue_sync: issue_sync} = context) do
57 | sync_github_issue_titles_actions =
58 | if issue_sync.sync_github_issue_titles do
59 | Actions.UpdateGithubIssue.new(%{
60 | title:
61 | ContentWriter.github_issue_title_from_linear(
62 | context.github_issue.title,
63 | context.linear_issue
64 | )
65 | })
66 | end
67 |
68 | close_on_open_actions =
69 | if issue_sync.close_on_open do
70 | [
71 | Actions.CreateGithubComment.new(%{
72 | body: ContentWriter.github_issue_moved_comment_body(context.linear_issue)
73 | }),
74 | Actions.UpdateGithubIssue.new(%{
75 | state: :closed
76 | })
77 | ]
78 | end
79 |
80 | Helpers.combine_actions([
81 | sync_github_issue_titles_actions,
82 | close_on_open_actions
83 | ])
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/lib/linear/actions/fetch_github_labels.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.FetchGithubLabels do
2 | require Logger
3 |
4 | alias Linear.Actions.Helpers
5 | alias Linear.GithubAPI.GithubData, as: Gh
6 |
7 | @enforce_keys []
8 | defstruct []
9 |
10 | def new(fields \\ %{}), do: struct(__MODULE__, fields)
11 |
12 | def requires?(dep), do: dep == :github
13 |
14 | def process(%__MODULE__{}, %{issue_sync: issue_sync} = context) do
15 | {client, repo_key} = Helpers.client_repo_key(issue_sync)
16 |
17 | with {200, repo_labels, _response} <-
18 | Helpers.github_api().list_repository_labels(client, repo_key),
19 | {200, issue_labels, _response} <-
20 | Helpers.github_api().list_issue_labels(
21 | client,
22 | repo_key,
23 | context.shared_issue.github_issue_number
24 | ) do
25 | context =
26 | Map.merge(context, %{
27 | github_repo_labels: Enum.map(repo_labels, &Gh.Label.new/1),
28 | github_issue_labels: Enum.map(issue_labels, &Gh.Label.new/1)
29 | })
30 |
31 | {:ok, context}
32 | else
33 | error ->
34 | Logger.error("Error fetching github labels: #{inspect(error)}")
35 |
36 | {:error, :fetch_github_labels}
37 | end
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/linear/actions/fetch_linear_issue.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.FetchLinearIssue do
2 | alias Linear.Actions.Helpers
3 | alias Linear.LinearAPI
4 | alias Linear.LinearQuery
5 | alias Linear.LinearAPI.LinearData, as: Ln
6 |
7 | @enforce_keys []
8 | defstruct [:issue_id, :issue_key, replace_shared_issue: false]
9 |
10 | def new(fields \\ %{}), do: struct(__MODULE__, fields)
11 |
12 | def requires?(_any), do: false
13 |
14 | def process(%__MODULE__{} = action, %{issue_sync: issue_sync} = context) do
15 | with nil <- action.issue_id,
16 | nil <- action.issue_key,
17 | nil <- context.shared_issue.linear_issue_id,
18 | nil <- context.linear_issue.id do
19 | {:error, :missing_issue_key}
20 | else
21 | issue_key when is_binary(issue_key) ->
22 | session = LinearAPI.Session.new(issue_sync.account)
23 |
24 | case LinearQuery.get_issue_by_id(session, issue_key) do
25 | {:ok, linear_issue_data} ->
26 | linear_issue = Ln.Issue.new(linear_issue_data)
27 |
28 | if action.replace_shared_issue do
29 | :ok = Helpers.delete_existing_shared_issue(linear_issue)
30 | end
31 |
32 | context.shared_issue
33 | |> Helpers.update_shared_issue(linear_issue)
34 | |> case do
35 | {:ok, shared_issue} ->
36 | context =
37 | context
38 | |> Map.put(:shared_issue, shared_issue)
39 | |> Map.put(:linear_issue, linear_issue)
40 |
41 | {:ok, context}
42 |
43 | {:error, reason} ->
44 | {:error, {:fetch_linear_issue, reason}}
45 | end
46 |
47 | :error ->
48 | {:error, :fetch_linear_issue}
49 | end
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/linear/actions/fetch_linear_labels.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.FetchLinearLabels do
2 | alias Linear.LinearAPI
3 | alias Linear.LinearQuery
4 | alias Linear.LinearAPI.LinearData, as: Ln
5 |
6 | @enforce_keys []
7 | defstruct []
8 |
9 | def new(fields \\ %{}), do: struct(__MODULE__, fields)
10 |
11 | def requires?(dep), do: dep == :linear
12 |
13 | def process(%__MODULE__{}, %{issue_sync: issue_sync} = context) do
14 | session = LinearAPI.Session.new(issue_sync.account)
15 |
16 | case LinearQuery.list_labels(session) do
17 | {:ok, issue_labels_data} ->
18 | context =
19 | Map.put(
20 | context,
21 | :linear_labels,
22 | Enum.map(issue_labels_data, &Ln.Label.new/1)
23 | )
24 |
25 | {:ok, context}
26 |
27 | :error ->
28 | {:error, :fetch_linear_labels}
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/linear/actions/helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.Helpers do
2 | import Ecto.Query, warn: false
3 |
4 | alias Linear.Repo
5 | alias Linear.GithubAPI
6 | alias Linear.Data.IssueSync
7 | alias Linear.Data.SharedIssue
8 | alias Linear.GithubAPI.GithubData, as: Gh
9 | alias Linear.LinearAPI.LinearData, as: Ln
10 |
11 | @doc """
12 | """
13 | def client_repo_key(%IssueSync{} = issue_sync) do
14 | issue_sync = Repo.preload(issue_sync, :account)
15 | {GithubAPI.client(issue_sync.account), GithubAPI.to_repo_key!(issue_sync)}
16 | end
17 |
18 | # TODO: Replace with dispatch module
19 | def github_api() do
20 | Application.get_env(:linear, :github_api, GithubAPI)
21 | end
22 |
23 | @doc """
24 | """
25 | def combine_actions(actions) do
26 | Enum.flat_map(List.wrap(actions), fn
27 | nil ->
28 | []
29 |
30 | actions ->
31 | List.wrap(actions)
32 | end)
33 | end
34 |
35 | @doc """
36 | """
37 | def update_shared_issue(shared_issue, %Gh.Issue{} = github_issue) do
38 | shared_issue
39 | |> Ecto.Changeset.change(
40 | github_issue_id: github_issue.id,
41 | github_issue_number: github_issue.number
42 | )
43 | |> Ecto.Changeset.unique_constraint(:github_issue_id)
44 | |> Repo.update()
45 | |> handle_constraint_error()
46 | end
47 |
48 | def update_shared_issue(shared_issue, %Ln.Issue{} = linear_issue) do
49 | shared_issue
50 | |> Ecto.Changeset.change(
51 | linear_issue_id: linear_issue.id,
52 | linear_issue_number: linear_issue.number
53 | )
54 | |> Ecto.Changeset.unique_constraint(:linear_issue_id)
55 | |> Repo.update()
56 | |> handle_constraint_error()
57 | end
58 |
59 | @doc """
60 | """
61 | def handle_constraint_error({:error, %Ecto.Changeset{}}),
62 | do: {:error, :invalid_constraint}
63 |
64 | def handle_constraint_error({:ok, _struct} = value), do: value
65 |
66 | @doc """
67 | """
68 | def delete_existing_shared_issue(%Gh.Issue{} = github_issue) do
69 | Repo.delete_all(from s in SharedIssue, where: s.github_issue_id == ^github_issue.id)
70 | :ok
71 | end
72 |
73 | def delete_existing_shared_issue(%Ln.Issue{} = linear_issue) do
74 | Repo.delete_all(from s in SharedIssue, where: s.linear_issue_id == ^linear_issue.id)
75 | :ok
76 | end
77 |
78 | defmodule Labels do
79 | @doc """
80 | """
81 | def to_label_mapset(labels) when is_list(labels) do
82 | labels |> Enum.reject(&(&1 == nil)) |> Enum.map(& &1.id) |> MapSet.new()
83 | end
84 |
85 | @doc """
86 | """
87 | def get_corresponding_linear_label(%Gh.Label{} = github_label, linear_labels) do
88 | Enum.find(linear_labels, fn %Ln.Label{} = linear_label ->
89 | if labels_match?(linear_label.name, github_label.name), do: linear_label, else: nil
90 | end)
91 | end
92 |
93 | @doc """
94 | Returns a map with the added and removed state ids, or `nil` if state was not updated.
95 | """
96 | def get_updated_linear_state(%{
97 | "data" => %{"stateId" => added_state_id},
98 | "updatedFrom" => %{"stateId" => removed_state_id}
99 | }) do
100 | %{
101 | added_state_id: added_state_id,
102 | removed_state_id: removed_state_id
103 | }
104 | end
105 |
106 | def get_updated_linear_state(_otherwise), do: nil
107 |
108 | @doc """
109 | Returns a map of added and removed label ids, or `nil` if none were updated.
110 | """
111 | def get_updated_linear_labels(%{
112 | "data" => %{"labelIds" => current_label_ids},
113 | "updatedFrom" => %{"labelIds" => prev_label_ids}
114 | }) do
115 | %{
116 | added_label_ids: current_label_ids -- prev_label_ids,
117 | removed_label_ids: prev_label_ids -- current_label_ids
118 | }
119 | end
120 |
121 | def get_updated_linear_labels(_otherwise), do: nil
122 |
123 | @doc """
124 | Checks if two labels are equal, uses a case-insensitive comparison.
125 | """
126 | def labels_match?(label_a, label_b) do
127 | String.downcase(label_a) == String.downcase(label_b)
128 | end
129 | end
130 | end
131 |
--------------------------------------------------------------------------------
/lib/linear/actions/remove_github_labels.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.RemoveGithubLabels do
2 | alias Linear.Actions.Helpers
3 | alias Linear.GithubAPI.GithubData, as: Gh
4 |
5 | @enforce_keys [:label_ids]
6 | defstruct [:label_ids]
7 |
8 | def new(fields), do: struct(__MODULE__, fields)
9 |
10 | def requires?(dep), do: dep == :github
11 |
12 | def process(%__MODULE__{label_ids: []}, context), do: {:ok, context}
13 |
14 | def process(%__MODULE__{} = action, context) do
15 | repo_labels = Map.fetch!(context, :github_repo_labels)
16 | issue_label_ids = Map.fetch!(context, :github_issue_labels) |> Enum.map(& &1.id)
17 | linear_labels = Map.fetch!(context, :linear_labels)
18 |
19 | repo_labels_to_remove =
20 | Enum.filter(repo_labels, fn %Gh.Label{} = repo_label ->
21 | if ln_label =
22 | Enum.find(linear_labels, &Helpers.Labels.labels_match?(&1.name, repo_label.name)) do
23 | ln_label.id in action.label_ids and repo_label.id in issue_label_ids
24 | end
25 | end)
26 |
27 | Enum.reduce_while(repo_labels_to_remove, {:ok, context}, &process_label/2)
28 | end
29 |
30 | defp process_label(github_label, {:ok, %{issue_sync: issue_sync} = context}) do
31 | {client, repo_key} = Helpers.client_repo_key(issue_sync)
32 |
33 | Helpers.github_api().remove_issue_labels(
34 | client,
35 | repo_key,
36 | context.shared_issue.github_issue_number,
37 | github_label.name
38 | )
39 | |> case do
40 | {200, _body, _response} ->
41 | {:cont, {:ok, context}}
42 |
43 | _otherwise ->
44 | {:halt, {:error, :add_github_labels}}
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/linear/actions/update_github_issue.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.UpdateGithubIssue do
2 | require Logger
3 |
4 | alias Linear.Actions.Helpers
5 | alias Linear.Util
6 |
7 | @enforce_keys []
8 | defstruct [:title, :state]
9 |
10 | def new(fields), do: struct(__MODULE__, fields)
11 |
12 | def requires?(dep), do: dep == :github
13 |
14 | def process(%__MODULE__{} = action, %{issue_sync: issue_sync} = context) do
15 | {client, repo_key} = Helpers.client_repo_key(issue_sync)
16 |
17 | params =
18 | %{}
19 | |> Util.Control.put_non_nil("title", action.title)
20 | |> Util.Control.put_non_nil("state", action.state, &Atom.to_string/1)
21 |
22 | Helpers.github_api().update_issue(
23 | client,
24 | repo_key,
25 | context.shared_issue.github_issue_number,
26 | params
27 | )
28 | |> case do
29 | {200, _body, _response} ->
30 | {:ok, context}
31 |
32 | error ->
33 | Logger.error("Error creating github issue comment: #{inspect(error)}")
34 |
35 | {:error, :update_github_issue}
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/linear/actions/update_linear_issue.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Actions.UpdateLinearIssue do
2 | alias Linear.LinearAPI
3 | alias Linear.LinearQuery
4 | alias Linear.Actions.Helpers
5 | alias Linear.Util
6 |
7 | @enforce_keys []
8 | defstruct [:state_id, add_labels: [], remove_labels: []]
9 |
10 | def new(fields), do: struct(__MODULE__, fields)
11 |
12 | def requires?(dep), do: dep == :linear
13 |
14 | def process(%__MODULE__{} = action, %{issue_sync: issue_sync} = context) do
15 | session = LinearAPI.Session.new(issue_sync.account)
16 |
17 | args = update_issue_args(action, context)
18 |
19 | case LinearQuery.update_issue(session, context.shared_issue.linear_issue_id, args) do
20 | :ok ->
21 | {:ok, context}
22 |
23 | :error ->
24 | {:error, :update_linear_issue}
25 | end
26 | end
27 |
28 | defp update_issue_args(%__MODULE__{state_id: nil} = action, context) do
29 | labels_to_add =
30 | action.add_labels
31 | |> Enum.map(&Helpers.Labels.get_corresponding_linear_label(&1, context.linear_labels))
32 | |> Helpers.Labels.to_label_mapset()
33 |
34 | labels_to_remove =
35 | action.remove_labels
36 | |> Enum.map(&Helpers.Labels.get_corresponding_linear_label(&1, context.linear_labels))
37 | |> Helpers.Labels.to_label_mapset()
38 |
39 | current_label_ids = Helpers.Labels.to_label_mapset(context.linear_issue.labels)
40 |
41 | updated_label_ids =
42 | current_label_ids
43 | |> MapSet.union(labels_to_add)
44 | |> MapSet.difference(labels_to_remove)
45 |
46 | labels_changed? = updated_label_ids != current_label_ids
47 |
48 | Util.Control.put_if([], :labelIds, MapSet.to_list(updated_label_ids), labels_changed?)
49 | end
50 |
51 | defp update_issue_args(%__MODULE__{} = action, _context) do
52 | Util.Control.put_non_nil([], :stateId, action.state_id)
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/linear/api/github_api.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.GithubAPI do
2 | @behaviour __MODULE__.Behaviour
3 |
4 | alias Tentacat.Client
5 | alias Linear.Accounts.Account
6 |
7 | @webhook_events ["issues", "issue_comment", "pull_request"]
8 |
9 | def client(account = %Account{}) do
10 | Tentacat.Client.new(%{access_token: account.github_token})
11 | end
12 |
13 | def to_repo_key!(%{repo_owner: repo_owner, repo_name: repo_name})
14 | when is_binary(repo_owner) and is_binary(repo_name),
15 | do: {repo_owner, repo_name}
16 |
17 | def user_id_by_username(username) when is_binary(username) do
18 | case Tentacat.Users.find(username) do
19 | {200, %{"id" => user_id}, _response} ->
20 | {:ok, user_id}
21 |
22 | {404, _body, _response} ->
23 | {:error, :not_found}
24 | end
25 | end
26 |
27 | @impl true
28 | def viewer(client = %Client{}) do
29 | {200, result, _response} = Tentacat.Users.me(client)
30 | result
31 | end
32 |
33 | @impl true
34 | def create_issue(client = %Client{}, {owner, repo}, params) do
35 | params = Map.take(params, ["title", "body"])
36 | Tentacat.Issues.create(client, owner, repo, params)
37 | end
38 |
39 | @impl true
40 | def close_issue(client = %Client{}, {owner, repo}, issue_number) do
41 | Tentacat.Issues.update(client, owner, repo, issue_number, %{"state" => "closed"})
42 | end
43 |
44 | @impl true
45 | def update_issue(client = %Client{}, {owner, repo}, issue_number, params) do
46 | params = Map.take(params, ["title", "state"])
47 | Tentacat.Issues.update(client, owner, repo, issue_number, params)
48 | end
49 |
50 | @impl true
51 | def list_repository_labels(client = %Client{}, {owner, repo}) do
52 | Tentacat.Repositories.Labels.list(client, owner, repo)
53 | end
54 |
55 | @impl true
56 | def list_issue_labels(client = %Client{}, {owner, repo}, issue_number) do
57 | Tentacat.Issues.Labels.list(client, owner, repo, issue_number)
58 | end
59 |
60 | @impl true
61 | def add_issue_labels(client = %Client{}, {owner, repo}, issue_number, label_ids) do
62 | Tentacat.Issues.Labels.add(client, owner, repo, issue_number, label_ids)
63 | end
64 |
65 | @impl true
66 | def remove_issue_labels(client = %Client{}, {owner, repo}, issue_number, label_id) do
67 | Tentacat.Issues.Labels.remove(client, owner, repo, issue_number, label_id)
68 | end
69 |
70 | @impl true
71 | def create_issue_comment(client = %Client{}, {owner, repo}, issue_number, body) do
72 | Tentacat.Issues.Comments.create(client, owner, repo, issue_number, %{"body" => body})
73 | end
74 |
75 | @impl true
76 | def create_webhook(client = %Client{}, {owner, repo}, opts) do
77 | Tentacat.Hooks.create(client, owner, repo, %{
78 | "name" => "web",
79 | "active" => true,
80 | "events" => @webhook_events,
81 | "config" => %{
82 | "url" => Keyword.fetch!(opts, :url),
83 | "secret" => Keyword.fetch!(opts, :secret),
84 | "content_type" => "json"
85 | }
86 | })
87 | end
88 |
89 | @impl true
90 | def delete_webhook(client = %Client{}, {owner, repo}, opts) do
91 | Tentacat.Hooks.remove(client, owner, repo, Keyword.fetch!(opts, :hook_id))
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/lib/linear/api/github_api/github_data.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.GithubAPI.GithubData do
2 | alias __MODULE__
3 |
4 | defmodule __MODULE__.Repo do
5 | @enforce_keys [:id, :full_name, :html_url]
6 | defstruct [:id, :full_name, :html_url]
7 |
8 | def new(attrs), do: GithubData.new(__MODULE__, attrs)
9 | end
10 |
11 | defmodule __MODULE__.Issue do
12 | @enforce_keys [:id, :title, :body, :number, :user, :html_url]
13 | defstruct [:id, :title, :body, :number, :user, :html_url]
14 |
15 | def new(attrs) do
16 | GithubData.new(__MODULE__, attrs)
17 | |> Map.update!(:user, &GithubData.User.new/1)
18 | end
19 | end
20 |
21 | defmodule __MODULE__.Comment do
22 | @enforce_keys [:id, :body, :user, :html_url]
23 | defstruct [:id, :body, :user, :html_url]
24 |
25 | def new(attrs) do
26 | GithubData.new(__MODULE__, attrs)
27 | |> Map.update!(:user, &GithubData.User.new/1)
28 | end
29 | end
30 |
31 | defmodule __MODULE__.Label do
32 | @enforce_keys [:id, :name]
33 | defstruct [:id, :name, :description, :color]
34 |
35 | def new(attrs), do: GithubData.new(__MODULE__, attrs)
36 | end
37 |
38 | defmodule __MODULE__.User do
39 | @enforce_keys [:id, :login, :html_url]
40 | defstruct [:id, :login, :html_url]
41 |
42 | def new(attrs), do: GithubData.new(__MODULE__, attrs)
43 | end
44 |
45 | def new(struct_module, attrs) do
46 | fields =
47 | struct_module.__struct__()
48 | |> Map.keys()
49 | |> Enum.map(fn key -> {key, attrs[to_string(key)]} end)
50 |
51 | struct(struct_module, fields)
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/linear/api/github_api_behaviour.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.GithubAPI.Behaviour do
2 | alias Tentacat.Client
3 |
4 | @type repo_key :: {String.t(), String.t()}
5 |
6 | @callback viewer(Client.t()) ::
7 | {:ok, map()} | {:error, map()}
8 |
9 | @callback create_issue(Client.t(), repo_key(), map()) ::
10 | {:ok, map()} | {:error, map()}
11 |
12 | @callback close_issue(Client.t(), repo_key(), Integer.t()) ::
13 | {:ok, map()} | {:error, map()}
14 |
15 | @callback update_issue(Client.t(), repo_key(), Integer.t(), map()) ::
16 | {:ok, map()} | {:error, map()}
17 |
18 | @callback list_repository_labels(Client.t(), repo_key()) ::
19 | {:ok, map()} | {:error, map()}
20 |
21 | @callback list_issue_labels(Client.t(), repo_key(), Integer.t()) ::
22 | {:ok, map()} | {:error, map()}
23 |
24 | @callback add_issue_labels(Client.t(), repo_key(), Integer.t(), list()) ::
25 | {:ok, map()} | {:error, map()}
26 |
27 | @callback remove_issue_labels(Client.t(), repo_key(), Integer.t(), String.t()) ::
28 | {:ok, map()} | {:error, map()}
29 |
30 | @callback create_issue_comment(Client.t(), repo_key(), Integer.t(), map()) ::
31 | {:ok, map()} | {:error, map()}
32 |
33 | @callback create_webhook(Client.t(), repo_key(), Keyword.t()) ::
34 | {:ok, map()} | {:error, map()}
35 |
36 | @callback delete_webhook(Client.t(), repo_key(), Keyword.t()) ::
37 | {:ok, map()} | {:error, map()}
38 | end
39 |
--------------------------------------------------------------------------------
/lib/linear/api/linear_api.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.LinearAPI do
2 | @moduledoc """
3 | Query the Linear GraphQL API.
4 | """
5 |
6 | use HTTPoison.Base
7 |
8 | @behaviour __MODULE__.Behaviour
9 |
10 | require Logger
11 |
12 | alias HTTPoison.Response
13 | alias GraphqlBuilder.Query
14 | alias __MODULE__.Session
15 |
16 | # 15s
17 | @timeout 15_000
18 | @base_url "https://api.linear.app"
19 |
20 | @impl __MODULE__.Behaviour
21 | def new_issue_sync_data(session = %Session{}, team_id) do
22 | query = %Query{
23 | operation: :team,
24 | variables: [id: team_id],
25 | fields: [
26 | labels: [nodes: [:id, :name, :archivedAt]],
27 | states: [nodes: [:id, :name, :description, :archivedAt]],
28 | members: [nodes: [:id, :name, :displayName]]
29 | ]
30 | }
31 |
32 | graphql(session, GraphqlBuilder.query(query))
33 | end
34 |
35 | @impl __MODULE__.Behaviour
36 | def viewer(session = %Session{}) do
37 | query = %Query{
38 | operation: :viewer,
39 | fields: [:id, :name, :email]
40 | }
41 |
42 | graphql(session, GraphqlBuilder.query(query))
43 | end
44 |
45 | @impl __MODULE__.Behaviour
46 | def organization(session = %Session{}) do
47 | query = %Query{
48 | operation: :organization,
49 | fields: [:id, :name]
50 | }
51 |
52 | graphql(session, GraphqlBuilder.query(query))
53 | end
54 |
55 | @impl __MODULE__.Behaviour
56 | def teams(session = %Session{}) do
57 | query = %Query{
58 | operation: :teams,
59 | fields: [nodes: [:id, :name]]
60 | }
61 |
62 | graphql(session, GraphqlBuilder.query(query))
63 | end
64 |
65 | @impl __MODULE__.Behaviour
66 | def viewer_teams(session = %Session{}) do
67 | graphql(session, """
68 | query {
69 | viewer {
70 | id
71 | name
72 | email
73 | }
74 | teams {
75 | nodes {
76 | id
77 | name
78 | }
79 | }
80 | }
81 | """)
82 | end
83 |
84 | @impl __MODULE__.Behaviour
85 | def issue(session = %Session{}, issue_id) when is_binary(issue_id) do
86 | query = """
87 | query($id: String!) {
88 | issue(id: $id) {
89 | id
90 | number
91 | url
92 | title
93 | description
94 | labels {
95 | nodes {
96 | id
97 | name
98 | }
99 | }
100 | }
101 | }
102 | """
103 |
104 | graphql(session, query, variables: [id: issue_id])
105 | end
106 |
107 | @impl __MODULE__.Behaviour
108 | def create_issue(session = %Session{}, opts) do
109 | query = """
110 | mutation($teamId: String!, $title: String!, $description: String!, $stateId: String, $labelIds: [String!], $assigneeId: String) {
111 | issueCreate(input: {teamId: $teamId, title: $title, description: $description, stateId: $stateId, labelIds: $labelIds, assigneeId: $assigneeId}) {
112 | success,
113 | issue {
114 | id,
115 | number,
116 | title,
117 | description,
118 | url,
119 | team {
120 | id,
121 | key,
122 | name
123 | }
124 | }
125 | }
126 | }
127 | """
128 |
129 | graphql(session, query,
130 | variables:
131 | Keyword.take(opts, [:teamId, :title, :description, :stateId, :labelIds, :assigneeId])
132 | )
133 | end
134 |
135 | @impl __MODULE__.Behaviour
136 | def create_comment(session = %Session{}, opts) do
137 | query = """
138 | mutation($issueId: String!, $body: String!) {
139 | commentCreate(input: {issueId: $issueId, body: $body}) {
140 | success,
141 | comment {
142 | id,
143 | body
144 | }
145 | }
146 | }
147 | """
148 |
149 | graphql(session, query, variables: Keyword.take(opts, [:issueId, :body]))
150 | end
151 |
152 | @impl __MODULE__.Behaviour
153 | def update_issue(session = %Session{}, opts) do
154 | query = """
155 | mutation($issueId: String!, $title: String, $description: String, $stateId: String, $labelIds: [String!], $assigneeId: String) {
156 | issueUpdate(id: $issueId, input: {title: $title, description: $description, stateId: $stateId, labelIds: $labelIds, assigneeId: $assigneeId}) {
157 | success,
158 | issue {
159 | id,
160 | number,
161 | title,
162 | description,
163 | url
164 | }
165 | }
166 | }
167 | """
168 |
169 | graphql(session, query,
170 | variables:
171 | Keyword.take(opts, [:issueId, :title, :description, :stateId, :labelIds, :assigneeId])
172 | )
173 | end
174 |
175 | @impl __MODULE__.Behaviour
176 | def create_webhook(session = %Session{}, opts) do
177 | resourceTypes = [resourceTypes: ["Comment", "Issue"]]
178 | opts = Keyword.merge(opts, resourceTypes)
179 |
180 | query = %Query{
181 | operation: :webhookCreate,
182 | variables: [input: Keyword.take(opts, [:url, :teamId, :resourceTypes])],
183 | fields: [:success, webhook: [:id, :enabled]]
184 | }
185 |
186 | graphql(session, GraphqlBuilder.mutation(query))
187 | end
188 |
189 | @impl __MODULE__.Behaviour
190 | def get_webhooks(session = %Session{}) do
191 | graphql(session, """
192 | query {
193 | teams {
194 | nodes {
195 | webhooks {
196 | nodes {
197 | id
198 | url
199 | enabled
200 | creator {
201 | name
202 | }
203 | }
204 | }
205 | }
206 | }
207 | }
208 | """)
209 | end
210 |
211 | @impl __MODULE__.Behaviour
212 | def delete_webhook(session = %Session{}, opts) do
213 | query = %Query{
214 | operation: :webhookDelete,
215 | variables: Keyword.take(opts, [:id]),
216 | fields: [:success]
217 | }
218 |
219 | graphql(session, GraphqlBuilder.mutation(query))
220 | end
221 |
222 | @impl __MODULE__.Behaviour
223 | def list_issue_labels(session = %Session{}) do
224 | graphql(session, """
225 | query {
226 | issueLabels {
227 | nodes {
228 | id
229 | name
230 | }
231 | }
232 | }
233 | """)
234 | end
235 |
236 | defp graphql(session = %Session{}, query, opts \\ []) when is_binary(query) do
237 | variables = Keyword.get(opts, :variables, []) |> Map.new()
238 |
239 | Logger.debug("Running graphql query #{query} with variables #{inspect(variables)}")
240 |
241 | headers = [
242 | {"Content-Type", "application/json"},
243 | {"Authorization", session.api_key}
244 | ]
245 |
246 | "/graphql"
247 | |> post(Jason.encode!(%{query: query, variables: variables}), headers)
248 | |> handle_response()
249 | end
250 |
251 | def handle_response({:ok, %Response{status_code: 200, body: body}}), do: {:ok, body}
252 | def handle_response({:ok, %Response{status_code: _, body: body}}), do: {:error, body}
253 |
254 | @impl true
255 | def process_request_url(url), do: @base_url <> url
256 |
257 | @impl true
258 | def process_response_body(body), do: Jason.decode!(body)
259 |
260 | @impl true
261 | def process_request_options(opts), do: Keyword.put(opts, :recv_timeout, @timeout)
262 | end
263 |
--------------------------------------------------------------------------------
/lib/linear/api/linear_api/linear_data.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.LinearAPI.LinearData do
2 | alias __MODULE__
3 |
4 | defmodule Issue do
5 | @enforce_keys [:id, :team_id, :title, :description, :number, :url, :team]
6 | defstruct [:id, :team_id, :title, :description, :number, :url, :team, :labels]
7 |
8 | def new(attrs) do
9 | LinearData.new(__MODULE__, attrs)
10 | |> Map.update!(:team, &LinearData.Team.new/1)
11 | |> Map.update!(:labels, &LinearData.Label.new_list/1)
12 | end
13 | end
14 |
15 | defmodule Comment do
16 | @enforce_keys [:id, :body]
17 | defstruct [:id, :body]
18 |
19 | def new(attrs) do
20 | LinearData.new(__MODULE__, attrs)
21 | end
22 | end
23 |
24 | defmodule Team do
25 | @enforce_keys [:id, :key]
26 | defstruct [:id, :key]
27 |
28 | def new(attrs) do
29 | LinearData.new(__MODULE__, attrs)
30 | end
31 | end
32 |
33 | defmodule Label do
34 | @enforce_keys [:id, :name]
35 | defstruct [:id, :name]
36 |
37 | def new(attrs) do
38 | LinearData.new(__MODULE__, attrs)
39 | end
40 |
41 | def new_list(%{"nodes" => labels}) do
42 | Enum.map(labels, &LinearData.new(__MODULE__, &1))
43 | end
44 |
45 | def new_list(_otherwise), do: nil
46 | end
47 |
48 | def new(struct_module, attrs) do
49 | fields =
50 | struct_module.__struct__()
51 | |> Map.keys()
52 | |> Enum.map(fn key -> {key, attrs[Inflex.camelize(key, :lower)]} end)
53 |
54 | struct(struct_module, fields)
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/linear/api/linear_api/session.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.LinearAPI.Session do
2 | @moduledoc """
3 | Represents a Linear GraphQL API session.
4 | """
5 |
6 | alias Linear.Accounts.Account
7 |
8 | @enforce_keys [:api_key]
9 | defstruct [:api_key]
10 |
11 | def new(), do: Application.fetch_env!(:linear, Linear.LinearAPI)[:api_key] |> new()
12 |
13 | def new(account = %Account{}), do: new(account.api_key)
14 |
15 | def new(api_key) when is_binary(api_key), do: %__MODULE__{api_key: api_key}
16 | end
17 |
--------------------------------------------------------------------------------
/lib/linear/api/linear_api_behaviour.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.LinearAPI.Behaviour do
2 | alias Linear.LinearAPI.Session
3 |
4 | @callback new_issue_sync_data(Session.t(), String.t()) :: {:ok, map()} | {:error, map()}
5 |
6 | @callback viewer(Session.t()) :: {:ok, map()} | {:error, map()}
7 |
8 | @callback organization(Session.t()) :: {:ok, map()} | {:error, map()}
9 |
10 | @callback teams(Session.t()) :: {:ok, map()} | {:error, map()}
11 |
12 | @callback viewer_teams(Session.t()) :: {:ok, map()} | {:error, map()}
13 |
14 | @callback issue(Session.t(), String.t()) :: {:ok, map()} | {:error, map()}
15 |
16 | @callback create_issue(Session.t(), Keyword.t()) :: {:ok, map()} | {:error, map()}
17 |
18 | @callback create_comment(Session.t(), Keyword.t()) :: {:ok, map()} | {:error, map()}
19 |
20 | @callback update_issue(Session.t(), Keyword.t()) :: {:ok, map()} | {:error, map()}
21 |
22 | @callback create_webhook(Session.t(), Keyword.t()) :: {:ok, map()} | {:error, map()}
23 |
24 | @callback get_webhooks(Session.t()) :: {:ok, map()} | {:error, map()}
25 |
26 | @callback delete_webhook(Session.t(), Keyword.t()) :: {:ok, map()} | {:error, map()}
27 |
28 | @callback list_issue_labels(Session.t()) :: {:ok, map()} | {:error, map()}
29 | end
30 |
--------------------------------------------------------------------------------
/lib/linear/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | def start(_type, _args) do
9 | children = [
10 | # Start the Ecto repository
11 | Linear.Repo,
12 | # Start the Telemetry supervisor
13 | LinearWeb.Telemetry,
14 | # Start the PubSub system
15 | {Phoenix.PubSub, name: Linear.PubSub},
16 | # Start the Endpoint (http/https)
17 | LinearWeb.Endpoint
18 | # Start a worker by calling: Linear.Worker.start_link(arg)
19 | # {Linear.Worker, arg}
20 | ]
21 |
22 | # See https://hexdocs.pm/elixir/Supervisor.html
23 | # for other strategies and supported options
24 | opts = [strategy: :one_for_one, name: Linear.Supervisor]
25 | Supervisor.start_link(children, opts)
26 | end
27 |
28 | # Tell Phoenix to update the endpoint configuration
29 | # whenever the application is updated.
30 | def config_change(changed, _new, removed) do
31 | LinearWeb.Endpoint.config_change(changed, removed)
32 | :ok
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/linear/auth/github.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Auth.Github do
2 | use OAuth2.Strategy
3 |
4 | @api_url "https://api.github.com"
5 |
6 | def client() do
7 | OAuth2.Client.new(
8 | strategy: __MODULE__,
9 | client_id: fetch_env!(:client_id),
10 | client_secret: fetch_env!(:client_secret),
11 | redirect_uri: fetch_env!(:redirect_uri),
12 | site: @api_url,
13 | authorize_url: "https://github.com/login/oauth/authorize",
14 | token_url: "https://github.com/login/oauth/access_token"
15 | )
16 | |> OAuth2.Client.put_serializer("application/json", Jason)
17 | end
18 |
19 | def authorize_url!(state) do
20 | scope = fetch_env!(:scope)
21 | OAuth2.Client.authorize_url!(client(), scope: format_scope(scope), state: state)
22 | end
23 |
24 | def get_token!(params \\ [], headers \\ [], opts \\ []) do
25 | OAuth2.Client.get_token!(client(), params, headers, opts)
26 | end
27 |
28 | @impl true
29 | def authorize_url(client, params) do
30 | OAuth2.Strategy.AuthCode.authorize_url(client, params)
31 | end
32 |
33 | @impl true
34 | def get_token(client, params, headers) do
35 | client
36 | |> put_header("accept", "application/json")
37 | |> OAuth2.Strategy.AuthCode.get_token(params, headers)
38 | end
39 |
40 | def delete_app_authorization!(access_token) when is_binary(access_token) do
41 | client_id = fetch_env!(:client_id)
42 | client_secret = fetch_env!(:client_secret)
43 |
44 | HTTPoison.request!(
45 | :delete,
46 | @api_url <> "/applications/#{client_id}/grant",
47 | Jason.encode!(%{access_token: access_token}),
48 | accept: "application/vnd.github.v3+json",
49 | authorization: auth_header(client_id, client_secret)
50 | )
51 | end
52 |
53 | def fetch_env!(key) do
54 | Application.fetch_env!(:linear, __MODULE__)[key]
55 | end
56 |
57 | def auth_header(client_id, client_secret) do
58 | "Basic " <> Base.encode64(client_id <> ":" <> client_secret)
59 | end
60 |
61 | def format_scope(nil), do: ""
62 | def format_scope(scope) when is_binary(scope), do: scope
63 | def format_scope(scope) when is_list(scope), do: Enum.join(scope, ",")
64 | end
65 |
--------------------------------------------------------------------------------
/lib/linear/auth/github_app.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Auth.GithubApp do
2 | use OAuth2.Strategy
3 |
4 | @api_url "https://api.github.com"
5 | @apps_url "https://github.com/apps"
6 |
7 | def client() do
8 | OAuth2.Client.new(
9 | strategy: __MODULE__,
10 | client_id: fetch_env!(:client_id),
11 | client_secret: fetch_env!(:client_secret),
12 | redirect_uri: fetch_env!(:redirect_uri),
13 | site: "https://api.github.com",
14 | authorize_url: "https://github.com/login/oauth/authorize",
15 | token_url: "https://github.com/login/oauth/access_token"
16 | )
17 | |> OAuth2.Client.put_serializer("application/json", Jason)
18 | end
19 |
20 | def authorize_url!(target_id) do
21 | query_params = URI.encode_query(%{suggested_target_id: target_id})
22 | @apps_url <> "/#{fetch_env!(:app_name)}/installations/new/permissions?#{query_params}"
23 | end
24 |
25 | def get_token!(params \\ [], headers \\ [], opts \\ []) do
26 | OAuth2.Client.get_token!(client(), params, headers, opts)
27 | end
28 |
29 | @impl true
30 | def authorize_url(client, params) do
31 | OAuth2.Strategy.AuthCode.authorize_url(client, params)
32 | end
33 |
34 | @impl true
35 | def get_token(client, params, headers) do
36 | client
37 | |> put_header("accept", "application/json")
38 | |> OAuth2.Strategy.AuthCode.get_token(params, headers)
39 | end
40 |
41 | def delete_app_authorization!(installation_id) when is_binary(installation_id) do
42 | HTTPoison.delete!(
43 | @api_url <> "/app/installations/#{installation_id}",
44 | accept: "application/vnd.github.v3+json",
45 | authorization: jwt_header()
46 | )
47 | end
48 |
49 | defp jwt_header() do
50 | claims = %{
51 | # Issued at time, 60 seconds in the past to allow for clock drift
52 | "iat" => timestamp(-60),
53 | # JWT expiration time (10 minute maximum)
54 | "exp" => timestamp(10 * 60),
55 | # GitHub App's identifier
56 | "iss" => fetch_env!(:app_id)
57 | }
58 |
59 | {:ok, jwt} = Joken.Signer.sign(claims, Joken.Signer.parse_config(:github_app_jwt))
60 |
61 | "Bearer #{jwt}"
62 | end
63 |
64 | defp timestamp(offset) do
65 | DateTime.utc_now()
66 | |> DateTime.add(offset, :second)
67 | |> DateTime.to_unix()
68 | end
69 |
70 | def fetch_env!(key) do
71 | Application.fetch_env!(:linear, __MODULE__)[key]
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/linear/data.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Data do
2 | @moduledoc """
3 | The Data context.
4 | """
5 |
6 | import Ecto.Query, warn: false
7 | alias Linear.Repo
8 |
9 | alias Linear.Accounts.Account
10 | alias Linear.Data.IssueSync
11 | alias Linear.Webhooks.{LinearWebhook, GithubWebhook}
12 |
13 | @doc """
14 | Returns the list of issue_syncs for an account.
15 | """
16 | def list_issue_syncs(account = %Account{}) do
17 | Repo.all(
18 | from i in IssueSync,
19 | where: [account_id: ^account.id],
20 | order_by: {:desc, :inserted_at}
21 | )
22 | end
23 |
24 | @doc """
25 | Gets a single issue_sync.
26 |
27 | Raises `Ecto.NoResultsError` if the Issue sync does not exist.
28 | """
29 | def get_issue_sync!(id), do: Repo.get!(IssueSync, id) |> Repo.preload([:account])
30 |
31 | def get_issue_sync_by!(opts), do: Repo.get_by!(IssueSync, opts) |> Repo.preload([:account])
32 |
33 | def list_issue_syncs_by_repo_id(repo_id) do
34 | Repo.all(
35 | from i in IssueSync,
36 | join: a in assoc(i, :account),
37 | where: i.repo_id == ^repo_id and i.enabled == true,
38 | preload: [account: a]
39 | )
40 | end
41 |
42 | def list_issue_syncs_by_team_id(team_id) do
43 | Repo.all(
44 | from i in IssueSync,
45 | join: a in assoc(i, :account),
46 | where: i.team_id == ^team_id and i.enabled == true,
47 | preload: [account: a]
48 | )
49 | end
50 |
51 | def list_issue_syncs_by_linear_issue_id(linear_issue_id) do
52 | Repo.all(
53 | from i in IssueSync,
54 | join: a in assoc(i, :account),
55 | join: s in assoc(i, :shared_issues),
56 | where: s.linear_issue_id == ^linear_issue_id and i.enabled == true,
57 | preload: [account: a]
58 | )
59 | end
60 |
61 | @doc """
62 | Creates a issue_sync.
63 | """
64 | def create_issue_sync(account = %Account{}, attrs \\ %{}) do
65 | %IssueSync{}
66 | |> IssueSync.assoc_changeset(account, attrs)
67 | |> Repo.insert()
68 | end
69 |
70 | @doc """
71 | Updates a issue_sync.
72 | """
73 | def update_issue_sync(%IssueSync{} = issue_sync, attrs) do
74 | issue_sync
75 | |> IssueSync.changeset(attrs)
76 | |> Repo.update()
77 | end
78 |
79 | @doc """
80 | Marks an issue_sync as enabled and associates webhooks.
81 | """
82 | def enable_issue_sync(
83 | %IssueSync{enabled: false} = issue_sync,
84 | %LinearWebhook{} = linear_webhook,
85 | %GithubWebhook{} = github_webhook
86 | ) do
87 | issue_sync
88 | |> Ecto.Changeset.change(enabled: true)
89 | |> Ecto.Changeset.put_assoc(:linear_internal_webhook, linear_webhook)
90 | |> Ecto.Changeset.put_assoc(:github_internal_webhook, github_webhook)
91 | |> Repo.update()
92 | end
93 |
94 | @doc """
95 | Marks an issue_sync as enabled and associates webhooks.
96 | """
97 | def disable_issue_sync(%IssueSync{enabled: true} = issue_sync) do
98 | issue_sync
99 | |> Ecto.Changeset.change(enabled: false)
100 | |> Ecto.Changeset.put_assoc(:linear_internal_webhook, nil)
101 | |> Ecto.Changeset.put_assoc(:github_internal_webhook, nil)
102 | |> Repo.update()
103 | end
104 |
105 | @doc """
106 | Deletes a issue_sync.
107 | """
108 | def delete_issue_sync(%IssueSync{} = issue_sync) do
109 | Repo.delete(issue_sync)
110 | end
111 |
112 | @doc """
113 | Deletes all issue_syncs for an account.
114 | """
115 | def delete_disabled_issue_syncs_for_account(%Account{} = account) do
116 | Repo.delete_all(
117 | from i in IssueSync,
118 | where: [account_id: ^account.id, enabled: false]
119 | )
120 |
121 | :ok
122 | end
123 |
124 | @doc """
125 | Returns an `%Ecto.Changeset{}` for tracking issue_sync changes.
126 | """
127 | def change_issue_sync(%IssueSync{} = issue_sync, attrs \\ %{}) do
128 | IssueSync.changeset(issue_sync, attrs)
129 | end
130 | end
131 |
--------------------------------------------------------------------------------
/lib/linear/data/issue_sync.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Data.IssueSync do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Linear.Accounts.Account
6 | alias Linear.Data.SharedIssue
7 | alias Linear.Webhooks.{LinearWebhook, GithubWebhook}
8 |
9 | @primary_key {:id, :binary_id, autogenerate: true}
10 | @foreign_key_type :binary_id
11 |
12 | schema "issue_syncs" do
13 | field :dest_name, :string
14 | field :enabled, :boolean, default: false
15 | field :external_id, :string
16 | field :label_id, :binary_id
17 | field :repo_id, :integer
18 | field :repo_owner, :string
19 | field :repo_name, :string
20 | field :assignee_id, :binary_id
21 | field :source_name, :string
22 | field :open_state_id, :binary_id
23 | field :close_state_id, :binary_id
24 | field :team_id, :binary_id
25 | field :linear_webhook_id, :binary_id
26 | field :github_webhook_id, :integer
27 | field :close_on_open, :boolean, default: false
28 | field :sync_linear_to_github, :boolean, default: false
29 | field :sync_github_issue_titles, :boolean, default: false
30 |
31 | belongs_to :account, Account
32 | has_many :shared_issues, SharedIssue
33 |
34 | belongs_to :linear_internal_webhook, LinearWebhook, on_replace: :nilify
35 | belongs_to :github_internal_webhook, GithubWebhook, on_replace: :nilify
36 |
37 | timestamps(type: :utc_datetime)
38 | end
39 |
40 | @doc false
41 | def changeset(issue_sync, attrs) do
42 | issue_sync
43 | |> cast(attrs, [
44 | :source_name,
45 | :dest_name,
46 | :enabled,
47 | :repo_id,
48 | :repo_owner,
49 | :repo_name,
50 | :team_id,
51 | :open_state_id,
52 | :close_state_id,
53 | :label_id,
54 | :assignee_id,
55 | :linear_webhook_id,
56 | :github_webhook_id,
57 | :close_on_open,
58 | :sync_linear_to_github,
59 | :sync_github_issue_titles
60 | ])
61 | |> validate_required([
62 | :source_name,
63 | :dest_name,
64 | :enabled,
65 | :repo_id,
66 | :repo_owner,
67 | :repo_name,
68 | :team_id,
69 | :sync_linear_to_github,
70 | :sync_github_issue_titles
71 | ])
72 | |> unsafe_validate_unique([:repo_id, :team_id], Linear.Repo,
73 | message: "there is already an issue sync for this team repo combination"
74 | )
75 | |> unique_constraint(:repo_id,
76 | name: :issue_syncs_team_id_repo_id_index,
77 | message: "there is already an issue sync for this team repo combination"
78 | )
79 | end
80 |
81 | @doc false
82 | def assoc_changeset(issue_sync, account = %Account{}, attrs) do
83 | issue_sync
84 | |> changeset(attrs)
85 | |> put_change(:external_id, generate_external_id())
86 | |> put_assoc(:account, account)
87 | end
88 |
89 | def generate_external_id() do
90 | Ecto.UUID.generate()
91 | |> String.split("-")
92 | |> List.last()
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/lib/linear/data/shared_issue.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Data.SharedIssue do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Linear.Data.IssueSync
6 | alias Linear.Synchronize.Event
7 |
8 | @primary_key {:id, :binary_id, autogenerate: true}
9 | @foreign_key_type :binary_id
10 |
11 | schema "shared_issues" do
12 | # Linear data
13 | field :linear_issue_id, :binary_id
14 | field :linear_issue_number, :integer
15 |
16 | # Github data
17 | field :github_issue_id, :integer
18 | field :github_issue_number, :integer
19 |
20 | # Associations
21 | belongs_to :issue_sync, IssueSync
22 |
23 | timestamps(type: :utc_datetime)
24 | end
25 |
26 | @doc false
27 | def event_changeset(shared_issue, %Event{} = event) do
28 | shared_issue
29 | |> cast(Event.attrs(event), source_attrs(event.source))
30 | |> validate_required(source_attrs(event.source))
31 | |> unique_constraint(:linear_issue_id)
32 | |> unique_constraint(:github_issue_id)
33 | end
34 |
35 | defp source_attrs(:github), do: [:issue_sync_id, :github_issue_id, :github_issue_number]
36 | defp source_attrs(:linear), do: [:issue_sync_id, :linear_issue_id, :linear_issue_number]
37 | end
38 |
--------------------------------------------------------------------------------
/lib/linear/data/shared_issue_lock.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Data.SharedIssueLock do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Linear.Repo
6 | alias Linear.Data.SharedIssue
7 |
8 | @primary_key {:id, :binary_id, autogenerate: true}
9 | @foreign_key_type :binary_id
10 |
11 | schema "shared_issue_locks" do
12 | field :expires_at, :utc_datetime
13 | belongs_to :shared_issue, SharedIssue
14 |
15 | timestamps(type: :utc_datetime, updated_at: false)
16 | end
17 |
18 | @doc """
19 | Acquires a lock on a shared_issue record by polling every second.
20 | """
21 | def acquire(%SharedIssue{} = shared_issue, opts \\ []) do
22 | do_acquire(shared_issue, Keyword.fetch!(opts, :max_attempts))
23 | end
24 |
25 | defp do_acquire(shared_issue, max_attempts) do
26 | case {acquire_now(shared_issue), max_attempts - 1} do
27 | {{:ok, _lock} = result, _attempts_remaining} ->
28 | result
29 |
30 | {{:error, _reason} = error, 0} ->
31 | error
32 |
33 | {{:error, _reason}, attempts_remaining} ->
34 | Process.sleep(1000)
35 | do_acquire(shared_issue, attempts_remaining)
36 | end
37 | end
38 |
39 | @doc """
40 | Acquires a lock on a shared_issue record with no retries.
41 | """
42 | def acquire_now(%SharedIssue{} = shared_issue) do
43 | %__MODULE__{}
44 | |> acquire_changeset(shared_issue)
45 | |> Repo.insert()
46 | end
47 |
48 | @doc """
49 | Releases a shared_issue_lock.
50 | """
51 | def release(%__MODULE__{} = shared_issue_lock) do
52 | Repo.delete(shared_issue_lock)
53 | end
54 |
55 | defp acquire_changeset(shared_issue_lock, %SharedIssue{} = shared_issue) do
56 | shared_issue_lock
57 | |> change(shared_issue_id: shared_issue.id, expires_at: Date)
58 | |> put_expires_at(in_seconds: 30)
59 | |> unique_constraint(:shared_issue_id, message: "issue is currently locked")
60 | end
61 |
62 | defp put_expires_at(changeset, opts) do
63 | expires_in = Keyword.fetch!(opts, :in_seconds)
64 | expires_at = DateTime.add(DateTime.utc_now(), expires_in, :second)
65 | expires_at = expires_at |> DateTime.truncate(:second)
66 | put_change(changeset, :expires_at, expires_at)
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/linear/issue_sync_service.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.IssueSyncService do
2 | require Logger
3 |
4 | alias Linear.Repo
5 | alias Linear.Accounts.Account
6 | alias Linear.Data
7 | alias Linear.Data.IssueSync
8 | alias Linear.LinearAPI
9 | alias Linear.GithubAPI
10 | alias Linear.Webhooks
11 |
12 | @doc """
13 | Enables an issue_sync, handles webhook installation logic.
14 | """
15 | def enable_issue_sync(%IssueSync{} = issue_sync) do
16 | issue_sync =
17 | Repo.preload(issue_sync, [
18 | :account,
19 | :linear_internal_webhook,
20 | :github_internal_webhook
21 | ])
22 |
23 | Repo.transaction(fn ->
24 | with :ok <- disable_issue_sync_legacy(issue_sync.account, issue_sync),
25 | {:ok, linear_webhook} <- Webhooks.create_webhook(:linear, issue_sync),
26 | {:ok, github_webhook} <- Webhooks.create_webhook(:github, issue_sync),
27 | {:ok, issue_sync} <- Data.enable_issue_sync(issue_sync, linear_webhook, github_webhook) do
28 | issue_sync
29 | else
30 | {:error, reason} ->
31 | Repo.rollback(reason)
32 | end
33 | end)
34 | end
35 |
36 | @doc """
37 | Disables all issue syncs for an account. Returns :ok if all were disabled
38 | and {:error, reason} in case that one fails.
39 |
40 | Note that this operation is not atomic.
41 | """
42 | def disable_issue_syncs_for_account(%Account{} = account) do
43 | account = Repo.preload(account, :issue_syncs)
44 |
45 | Enum.reduce_while(account.issue_syncs, :ok, fn issue_sync, :ok ->
46 | with %IssueSync{enabled: true} <- issue_sync,
47 | {:ok, _issue_sync} <- disable_issue_sync(issue_sync) do
48 | {:cont, :ok}
49 | else
50 | %IssueSync{enabled: false} ->
51 | {:cont, :ok}
52 |
53 | {:error, reason} ->
54 | {:halt, {:error, reason}}
55 | end
56 | end)
57 | end
58 |
59 | @doc """
60 | Disables an issue_sync, handles webhook uninstallation logic.
61 | """
62 | def disable_issue_sync(%IssueSync{} = issue_sync) do
63 | issue_sync =
64 | Repo.preload(issue_sync, [
65 | :account,
66 | :linear_internal_webhook,
67 | :github_internal_webhook
68 | ])
69 |
70 | Repo.transaction(fn ->
71 | with :ok <- disable_issue_sync_legacy(issue_sync.account, issue_sync),
72 | %{linear_internal_webhook: linear_internal_webhook} <- issue_sync,
73 | %{github_internal_webhook: github_internal_webhook} <- issue_sync,
74 | {:ok, issue_sync} <- Data.disable_issue_sync(issue_sync),
75 | {:ok, _linear_webhook} <- Webhooks.delete_webhook(linear_internal_webhook, issue_sync),
76 | {:ok, _github_webhook} <- Webhooks.delete_webhook(github_internal_webhook, issue_sync) do
77 | issue_sync
78 | else
79 | {:error, reason} ->
80 | Repo.rollback(reason)
81 | end
82 | end)
83 | end
84 |
85 | # Legacy webhook handling functions, webhooks are now fully handled in
86 | # the Linear.Webhooks context.
87 |
88 | defp disable_issue_sync_legacy(
89 | account = %Account{id: id},
90 | issue_sync = %IssueSync{account_id: id}
91 | ) do
92 | multi =
93 | Ecto.Multi.new()
94 | |> Ecto.Multi.update(:issue_sync, Data.change_issue_sync(issue_sync, %{enabled: false}))
95 |
96 | multi =
97 | if issue_sync.linear_webhook_id != nil do
98 | multi
99 | |> Ecto.Multi.run(:linear_webhook, fn repo, multi ->
100 | disable_linear_webhook(account, repo, multi)
101 | end)
102 | else
103 | multi
104 | end
105 |
106 | multi =
107 | if issue_sync.github_webhook_id != nil do
108 | multi
109 | |> Ecto.Multi.run(:github_webhook, fn repo, multi ->
110 | disable_github_webhook(account, repo, multi)
111 | end)
112 | else
113 | multi
114 | end
115 |
116 | multi
117 | |> Repo.transaction()
118 | |> handle_result()
119 | end
120 |
121 | defp disable_linear_webhook(account, repo, multi) do
122 | result =
123 | LinearAPI.delete_webhook(LinearAPI.Session.new(account),
124 | id: multi.issue_sync.linear_webhook_id
125 | )
126 |
127 | do_disable = fn ->
128 | multi.issue_sync
129 | |> Data.change_issue_sync(%{linear_webhook_id: nil})
130 | |> repo.update()
131 | end
132 |
133 | case result do
134 | {:ok, %{"data" => %{"webhookDelete" => %{"success" => true}}}} ->
135 | do_disable.()
136 |
137 | {:ok, %{"data" => nil, "errors" => [%{"message" => "Entity not found"}]}} ->
138 | do_disable.()
139 |
140 | error ->
141 | Logger.error("Failed to disable Linear webhook, #{inspect(error)}")
142 | {:error, :linear_webhook_disable_failure}
143 | end
144 | end
145 |
146 | defp disable_github_webhook(account, repo, multi) do
147 | client = GithubAPI.client(account)
148 | repo_key = GithubAPI.to_repo_key!(multi.issue_sync)
149 |
150 | result =
151 | GithubAPI.delete_webhook(client, repo_key, hook_id: multi.issue_sync.github_webhook_id)
152 |
153 | do_disable = fn ->
154 | multi.issue_sync
155 | |> Data.change_issue_sync(%{github_webhook_id: nil})
156 | |> repo.update()
157 | end
158 |
159 | case result do
160 | {204, _body, _response} ->
161 | do_disable.()
162 |
163 | {404, _body, _response} ->
164 | do_disable.()
165 |
166 | {_status, error, _response} ->
167 | Logger.error("Failed to disable Github webhook, #{inspect(error)}")
168 | {:error, :github_webhook_disable_failure}
169 | end
170 | end
171 |
172 | defp handle_result({:ok, _multi}), do: :ok
173 | defp handle_result({:error, _step, reason, _multi}), do: {:error, reason}
174 | end
175 |
--------------------------------------------------------------------------------
/lib/linear/linear_query.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.LinearQuery do
2 | require Logger
3 |
4 | alias Linear.LinearAPI
5 | alias Linear.LinearAPI.Session
6 | alias Linear.LinearAPI.LinearData, as: Ln
7 |
8 | @linear_api Application.compile_env(:linear, :linear_api, LinearAPI)
9 |
10 | @doc """
11 | Gets an issue in Linear by ID, i.e. "ABC-12"
12 | """
13 | def get_issue_by_id(%Session{} = session, ln_issue_id) do
14 | result = @linear_api.issue(session, ln_issue_id)
15 |
16 | case result do
17 | {:ok, %{"data" => %{"issue" => issue}}} ->
18 | {:ok, issue}
19 |
20 | error ->
21 | Logger.error("Error getting Linear issue by ID, #{inspect(error)}")
22 | :error
23 | end
24 | end
25 |
26 | @doc """
27 | Creates an issue in Linear
28 | """
29 | def create_issue(%Session{} = session, args) do
30 | result = @linear_api.create_issue(session, args)
31 |
32 | case result do
33 | {:ok, %{"data" => %{"issueCreate" => %{"success" => true, "issue" => attrs}}}} ->
34 | {:ok, attrs}
35 |
36 | error ->
37 | Logger.error("Error syncing Github issue to Linear, #{inspect(error)}")
38 | :error
39 | end
40 | end
41 |
42 | @doc """
43 | Updates an issue in Linear.
44 |
45 | Possible args: [:title, :description, :stateId, :labelIds, :assigneeId]
46 | """
47 | def update_issue(%Session{} = session, %Ln.Issue{} = ln_issue, args),
48 | do: update_issue(%Session{} = session, ln_issue.id, args)
49 |
50 | def update_issue(%Session{} = session, ln_issue_id, args) do
51 | args = Keyword.merge(args, issueId: ln_issue_id)
52 |
53 | result = @linear_api.update_issue(session, args)
54 |
55 | case result do
56 | {:ok, %{"data" => %{"issueUpdate" => %{"success" => true}}}} ->
57 | :ok
58 |
59 | error ->
60 | Logger.error("Error updating Linear issue, #{inspect(error)}")
61 | :error
62 | end
63 | end
64 |
65 | @doc """
66 | Creates a comment on an issue in Linear.
67 |
68 | Possible args: [:body]
69 | """
70 | def create_issue_comment(%Session{} = session, %Ln.Issue{} = ln_issue, args),
71 | do: create_issue_comment(session, ln_issue.id, args)
72 |
73 | def create_issue_comment(%Session{} = session, ln_issue_id, args) do
74 | args = Keyword.merge(args, issueId: ln_issue_id)
75 |
76 | result = @linear_api.create_comment(session, args)
77 |
78 | case result do
79 | {:ok, %{"data" => %{"commentCreate" => %{"success" => true, "comment" => attrs}}}} ->
80 | {:ok, attrs}
81 |
82 | error ->
83 | Logger.error("Error creating Linear issue comment, #{inspect(error)}")
84 | :error
85 | end
86 | end
87 |
88 | @doc """
89 | Lists all labels currently applied to an issue in Linear.
90 | """
91 | def list_issue_labels(%Session{} = session, %Ln.Issue{} = ln_issue),
92 | do: list_issue_labels(session, ln_issue.id)
93 |
94 | def list_issue_labels(%Session{} = session, ln_issue_id) do
95 | result = @linear_api.issue(session, ln_issue_id)
96 |
97 | case result do
98 | {:ok, %{"data" => %{"issue" => issue}}} ->
99 | {:ok, issue["labels"]["nodes"]}
100 |
101 | error ->
102 | Logger.error("Error listing Linear issue label ids, #{inspect(error)}")
103 | :error
104 | end
105 | end
106 |
107 | @doc """
108 | Lists all labels in Linear.
109 | """
110 | def list_labels(%Session{} = session) do
111 | result = @linear_api.list_issue_labels(session)
112 |
113 | case result do
114 | {:ok, %{"data" => %{"issueLabels" => %{"nodes" => issue_labels}}}} ->
115 | {:ok, issue_labels}
116 |
117 | error ->
118 | Logger.error("Error listing Linear labels, #{inspect(error)}")
119 | :error
120 | end
121 | end
122 | end
123 |
--------------------------------------------------------------------------------
/lib/linear/release.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Release do
2 | @moduledoc """
3 | Used for executing DB release tasks when run in production without Mix
4 | installed.
5 | """
6 | @app :linear
7 |
8 | def migrate do
9 | load_app()
10 |
11 | for repo <- repos() do
12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
13 | end
14 | end
15 |
16 | def rollback(repo, version) do
17 | load_app()
18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
19 | end
20 |
21 | defp repos do
22 | Application.fetch_env!(@app, :ecto_repos)
23 | end
24 |
25 | defp load_app do
26 | Application.load(@app)
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/linear/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Repo do
2 | use Ecto.Repo,
3 | otp_app: :linear,
4 | adapter: Ecto.Adapters.Postgres
5 | end
6 |
--------------------------------------------------------------------------------
/lib/linear/synchronize.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Synchronize do
2 | @moduledoc false
3 |
4 | require Logger
5 |
6 | alias Linear.Actions
7 | alias Linear.Synchronize.SyncEngine
8 | alias Linear.Synchronize.Event
9 | alias Linear.GithubAPI.GithubData, as: Gh
10 | alias Linear.LinearAPI.LinearData, as: Ln
11 |
12 | @doc """
13 | Given a scope, handles an incoming webhook message.
14 | """
15 | def handle_incoming(:github, %{"action" => "opened", "repository" => gh_repo} = params) do
16 | %Event{
17 | source: :github,
18 | action: :opened_issue,
19 | data: %{
20 | github_repo: Gh.Repo.new(gh_repo),
21 | github_issue: Gh.Issue.new(params["issue"] || params["pull_request"])
22 | }
23 | }
24 | |> SyncEngine.handle_event()
25 | end
26 |
27 | def handle_incoming(:github, %{"action" => "reopened", "repository" => gh_repo} = params) do
28 | %Event{
29 | source: :github,
30 | action: :reopened_issue,
31 | data: %{
32 | github_repo: Gh.Repo.new(gh_repo),
33 | github_issue: Gh.Issue.new(params["issue"] || params["pull_request"])
34 | }
35 | }
36 | |> SyncEngine.handle_event()
37 | end
38 |
39 | def handle_incoming(:github, %{"action" => "closed", "repository" => gh_repo} = params) do
40 | %Event{
41 | source: :github,
42 | action: :closed_issue,
43 | data: %{
44 | github_repo: Gh.Repo.new(gh_repo),
45 | github_issue: Gh.Issue.new(params["issue"] || params["pull_request"])
46 | }
47 | }
48 | |> SyncEngine.handle_event()
49 | end
50 |
51 | def handle_incoming(:github, %{
52 | "action" => "created",
53 | "comment" => gh_comment,
54 | "issue" => gh_issue,
55 | "repository" => gh_repo
56 | }) do
57 | %Event{
58 | source: :github,
59 | action: :created_comment,
60 | data: %{
61 | github_repo: Gh.Repo.new(gh_repo),
62 | github_issue: Gh.Issue.new(gh_issue),
63 | github_comment: Gh.Comment.new(gh_comment)
64 | }
65 | }
66 | |> SyncEngine.handle_event()
67 | end
68 |
69 | def handle_incoming(
70 | :github,
71 | %{"action" => "labeled", "label" => gh_label, "repository" => gh_repo} = params
72 | ) do
73 | %Event{
74 | source: :github,
75 | action: :labeled_issue,
76 | data: %{
77 | github_repo: Gh.Repo.new(gh_repo),
78 | github_issue: Gh.Issue.new(params["issue"] || params["pull_request"]),
79 | github_label: Gh.Label.new(gh_label)
80 | }
81 | }
82 | |> SyncEngine.handle_event()
83 | end
84 |
85 | def handle_incoming(
86 | :github,
87 | %{"action" => "unlabeled", "label" => gh_label, "repository" => gh_repo} = params
88 | ) do
89 | %Event{
90 | source: :github,
91 | action: :unlabeled_issue,
92 | data: %{
93 | github_repo: Gh.Repo.new(gh_repo),
94 | github_issue: Gh.Issue.new(params["issue"] || params["pull_request"]),
95 | github_label: Gh.Label.new(gh_label)
96 | }
97 | }
98 | |> SyncEngine.handle_event()
99 | end
100 |
101 | def handle_incoming(:linear, %{"action" => "create", "type" => "Issue", "data" => ln_issue}) do
102 | %Event{
103 | source: :linear,
104 | action: :created_issue,
105 | data: %{
106 | linear_issue: Ln.Issue.new(ln_issue)
107 | }
108 | }
109 | |> SyncEngine.handle_event()
110 | end
111 |
112 | def handle_incoming(
113 | :linear,
114 | %{"action" => "update", "type" => "Issue", "data" => ln_issue} = params
115 | ) do
116 | %Event{
117 | source: :linear,
118 | action: :updated_issue,
119 | data: %{
120 | linear_issue: Ln.Issue.new(ln_issue),
121 | linear_state_diff: Actions.Helpers.Labels.get_updated_linear_state(params),
122 | linear_labels_diff: Actions.Helpers.Labels.get_updated_linear_labels(params)
123 | }
124 | }
125 | |> SyncEngine.handle_event()
126 | end
127 |
128 | def handle_incoming(:linear, %{
129 | "action" => "create",
130 | "type" => "Comment",
131 | "data" => %{"issue" => ln_issue} = ln_comment
132 | }) do
133 | %Event{
134 | source: :linear,
135 | action: :created_comment,
136 | data: %{
137 | linear_issue: Ln.Issue.new(ln_issue),
138 | linear_comment: Ln.Comment.new(ln_comment)
139 | }
140 | }
141 | |> SyncEngine.handle_event()
142 | end
143 |
144 | def handle_incoming(scope, params) do
145 | Logger.warn("Unhandled action in scope #{scope} => #{params["action"] || "?"}")
146 | end
147 | end
148 |
--------------------------------------------------------------------------------
/lib/linear/synchronize/content_writer.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Synchronize.ContentWriter do
2 | alias Linear.GithubAPI.GithubData, as: Gh
3 | alias Linear.LinearAPI.LinearData, as: Ln
4 |
5 | @doc """
6 | Returns the Linear issue title for a Github repo / issue combination.
7 | """
8 | def linear_issue_title(%Gh.Repo{} = gh_repo, %Gh.Issue{} = gh_issue) do
9 | """
10 | "#{gh_issue.title}" (#{gh_repo.full_name} ##{gh_issue.number})
11 | """
12 | end
13 |
14 | @doc """
15 | Returns the Linear issue body for a Github repo / issue combination.
16 | """
17 | def linear_issue_body(%Gh.Repo{} = gh_repo, %Gh.Issue{} = gh_issue) do
18 | issue_name = "#{gh_repo.full_name} ##{gh_issue.number}"
19 |
20 | """
21 | #{gh_issue.body}
22 |
23 | #{unless gh_issue.body == "", do: "___"}
24 |
25 | [#{issue_name}](#{gh_issue.html_url}) #{github_author_signature(gh_issue.user)}
26 |
27 | *via LinearSync*
28 | """
29 | end
30 |
31 | @doc """
32 | Returns the Linear comment body for a Github comment.
33 | """
34 | def linear_comment_body(%Gh.Comment{} = gh_comment) do
35 | """
36 | #{gh_comment.body}
37 | ___
38 | [Comment](#{gh_comment.html_url}) #{github_author_signature(gh_comment.user)}
39 |
40 | *via LinearSync*
41 | """
42 | end
43 |
44 | @doc """
45 | Returns the Linear comment body for a successfully linked Github issue.
46 | """
47 | def linear_comment_issue_linked_body(%Gh.Issue{} = gh_issue) do
48 | """
49 | Linked to [##{gh_issue.number}](#{gh_issue.html_url}) #{github_author_signature(gh_issue.user)}
50 |
51 | *via LinearSync*
52 | """
53 | end
54 |
55 | @doc """
56 | Returns a bracketed Linear issue key, i.e. "[AB-123]"
57 |
58 | Accepts a `brackets: false` opt to return an issue key without brackets.
59 | """
60 | def linear_issue_key(ln_issue, opts \\ [])
61 |
62 | def linear_issue_key(%Ln.Issue{team: %Ln.Team{key: team_key}, number: number}, opts)
63 | when not is_nil(team_key) and not is_nil(number) do
64 | issue_key = "#{team_key}-#{number}"
65 | if Keyword.get(opts, :brackets, true), do: "[#{issue_key}]", else: issue_key
66 | end
67 |
68 | def linear_issue_key(%Ln.Issue{} = _otherwise, _opts), do: nil
69 |
70 | @doc """
71 | Returns the Github issue body for a given linear issue.
72 | """
73 | def github_issue_body(%Ln.Issue{} = ln_issue) do
74 | """
75 | #{ln_issue.description}
76 |
77 | #{if ln_issue.description, do: "___"}
78 |
79 | Automatically created from [Linear (##{ln_issue.number})](#{ln_issue.url})
80 |
81 | *via LinearSync*
82 | """
83 | end
84 |
85 | @doc """
86 | Returns a signature for Linear issues with a link to the Github author.
87 | """
88 | def github_author_signature(%Gh.User{} = gh_user) do
89 | """
90 | by [@#{gh_user.login}](#{gh_user.html_url}) on GitHub
91 | """
92 | end
93 |
94 | @doc """
95 | Returns the Github comment body for when an issue was moved to Linear.
96 | """
97 | def github_issue_comment_body(%Ln.Issue{} = ln_issue, body) do
98 | """
99 | Comment from [Linear (##{ln_issue.number})](#{ln_issue.url})
100 |
101 | #{body}
102 |
103 | ---
104 | *via LinearSync*
105 | """
106 | end
107 |
108 | @doc """
109 | Returns the Github comment body for when an issue was moved to Linear.
110 | """
111 | def github_issue_moved_comment_body(%Ln.Issue{} = ln_issue) do
112 | """
113 | Automatically moved to [Linear (##{ln_issue.number})](#{ln_issue.url})
114 |
115 | ---
116 | *via LinearSync*
117 | """
118 | end
119 |
120 | @doc """
121 | Returns the title for a Github issue updated from Linear.
122 | """
123 | def github_issue_title_from_linear(%Ln.Issue{} = ln_issue) do
124 | github_issue_title_from_linear(ln_issue.title, ln_issue)
125 | end
126 |
127 | def github_issue_title_from_linear(original_title, %Ln.Issue{} = ln_issue) do
128 | original_title <> " " <> linear_issue_key(ln_issue)
129 | end
130 |
131 | @doc """
132 | Returns true if the text contains the LinearSync comment signature.
133 | """
134 | def via_linear_sync?(body) when is_binary(body) do
135 | String.contains?(body, "*via LinearSync*")
136 | end
137 |
138 | def via_linear_sync?(nil), do: false
139 |
140 | @doc """
141 | Parses Linear issue keys from a binary.
142 |
143 | ## Examples
144 |
145 | iex> parse_linear_issue_keys("[LN-93] My Github issue")
146 | ["[LN-93]"]
147 |
148 | """
149 | def parse_linear_issue_keys(title) when is_binary(title) do
150 | Regex.scan(~r/\[([A-Z0-9]+-\d+)\]/, title) |> Enum.map(&List.last/1)
151 | end
152 | end
153 |
--------------------------------------------------------------------------------
/lib/linear/synchronize/event.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Synchronize.Event do
2 | alias __MODULE__
3 |
4 | @enforce_keys [
5 | :source,
6 | :action,
7 | :data
8 | ]
9 |
10 | defstruct [
11 | :source,
12 | :action,
13 | :issue_sync_id,
14 | data: %{}
15 | ]
16 |
17 | @doc """
18 | Returns the possible attrs for an event.
19 | """
20 | def attrs(%Event{data: data} = event) do
21 | %{
22 | github_issue_id: data[:github_issue] && data.github_issue.id,
23 | github_issue_number: data[:github_issue] && data.github_issue.number,
24 | github_comment_id: data[:github_comment] && data.github_comment.id,
25 | linear_issue_id: data[:linear_issue] && data.linear_issue.id,
26 | linear_issue_number: data[:linear_issue] && data.linear_issue.number,
27 | linear_comment_id: data[:linear_comment] && data.linear_comment.id,
28 | issue_sync_id: event.issue_sync_id
29 | }
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/linear/synchronize/sync_engine.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Synchronize.SyncEngine do
2 | require Logger
3 |
4 | alias Linear.Repo
5 | alias Linear.Actions
6 | alias Linear.Data
7 | alias Linear.Data.IssueSync
8 | alias Linear.Data.SharedIssue
9 | alias Linear.Data.SharedIssueLock
10 | alias Linear.Synchronize.Event
11 |
12 | @doc """
13 | """
14 | def handle_event(%Event{} = event) do
15 | event
16 | |> list_issue_syncs()
17 | |> Enum.map(&handle_issue_sync_event(&1, event))
18 | end
19 |
20 | @doc """
21 | """
22 | def handle_issue_sync_event(%IssueSync{} = issue_sync, %Event{} = event) do
23 | event = %Event{event | issue_sync_id: issue_sync.id}
24 |
25 | with {:error, :not_found} <- get_shared_issue(event),
26 | {:error, :invalid_constraint} <- create_shared_issue(event) do
27 | :noop
28 | else
29 | {:ok, shared_issue} ->
30 | context =
31 | Map.merge(event.data, %{
32 | issue_sync: issue_sync,
33 | shared_issue: shared_issue
34 | })
35 |
36 | Repo.transaction(fn ->
37 | {:ok, lock} = SharedIssueLock.acquire(shared_issue, max_attempts: 10)
38 |
39 | result =
40 | event
41 | |> Actions.for_event(context)
42 | |> Actions.Helpers.combine_actions()
43 | |> recurse_actions(context, &process_action/2)
44 |
45 | with {:ok, _context} <- result do
46 | {:ok, _lock} = SharedIssueLock.release(lock)
47 | end
48 | end)
49 | end
50 | end
51 |
52 | defp recurse_actions([], context, _process_fun), do: {:ok, context}
53 |
54 | defp recurse_actions([action | actions], context, process_fun) do
55 | case process_fun.(action, context) do
56 | {:ok, context} ->
57 | recurse_actions(actions, context, process_fun)
58 |
59 | {:cont, {context, []}} ->
60 | recurse_actions(actions, context, process_fun)
61 |
62 | {:cont, {context, child_actions}} ->
63 | child_actions = List.wrap(child_actions)
64 | recurse_actions(Enum.concat(child_actions, actions), context, process_fun)
65 |
66 | {:error, reason} = error ->
67 | Logger.error("[SyncEngine.recurse_actions/3] Exiting with reason: #{inspect(reason)}")
68 | error
69 | end
70 | end
71 |
72 | defp process_action(%action_type{} = action, context) do
73 | Logger.info("[SyncEngine.process_action/2] Processing action: #{inspect(action_type)}")
74 |
75 | should_process? =
76 | (not action_type.requires?(:linear) or context.shared_issue.linear_issue_id != nil) and
77 | (not action_type.requires?(:github) or context.shared_issue.github_issue_id != nil)
78 |
79 | if should_process? do
80 | action_type.process(action, context)
81 | else
82 | {:error, {:missing_requirement, action_type}}
83 | end
84 | end
85 |
86 | @doc """
87 | """
88 | def list_issue_syncs(%Event{source: :github} = event) do
89 | Data.list_issue_syncs_by_repo_id(event.data.github_repo.id)
90 | end
91 |
92 | def list_issue_syncs(%Event{source: :linear} = event) do
93 | cond do
94 | team_id = event.data.linear_issue.team_id ->
95 | Data.list_issue_syncs_by_team_id(team_id)
96 |
97 | linear_issue_id = event.data.linear_issue.id ->
98 | Data.list_issue_syncs_by_linear_issue_id(linear_issue_id)
99 | end
100 | end
101 |
102 | @doc """
103 | """
104 | def get_shared_issue(%Event{source: :github} = event) do
105 | query = [
106 | github_issue_id: event.data.github_issue.id,
107 | issue_sync_id: event.issue_sync_id
108 | ]
109 |
110 | Repo.get_by(SharedIssue, query) |> wrap_shared_issue_result()
111 | end
112 |
113 | def get_shared_issue(%Event{source: :linear} = event) do
114 | query = [
115 | linear_issue_id: event.data.linear_issue.id,
116 | issue_sync_id: event.issue_sync_id
117 | ]
118 |
119 | Repo.get_by(SharedIssue, query) |> wrap_shared_issue_result()
120 | end
121 |
122 | defp wrap_shared_issue_result(nil), do: {:error, :not_found}
123 | defp wrap_shared_issue_result(shared_issue), do: {:ok, shared_issue}
124 |
125 | @doc """
126 | """
127 | def create_shared_issue(%Event{} = event) do
128 | %SharedIssue{}
129 | |> SharedIssue.event_changeset(event)
130 | |> Repo.insert()
131 | |> Actions.Helpers.handle_constraint_error()
132 | end
133 | end
134 |
--------------------------------------------------------------------------------
/lib/linear/util.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Util do
2 | defmodule Control do
3 | def put_if(opts, key, val, condition),
4 | do: if(condition, do: put_keymap(opts, key, val), else: opts)
5 |
6 | def put_non_nil(opts, key, val, process \\ & &1)
7 | def put_non_nil(opts, _key, nil, _process), do: opts
8 | def put_non_nil(opts, key, val, process), do: put_keymap(opts, key, process.(val))
9 |
10 | def put_keymap(opts, key, val) when is_list(opts), do: Keyword.put(opts, key, val)
11 | def put_keymap(opts, key, val) when is_map(opts), do: Map.put(opts, key, val)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/linear/webhooks/github_webhook.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Webhooks.GithubWebhook do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Linear.Data.IssueSync
6 |
7 | @primary_key {:id, :binary_id, autogenerate: true}
8 | @foreign_key_type :binary_id
9 |
10 | schema "github_webhooks" do
11 | field :repo_id, :integer
12 | field :repo_owner, :string
13 | field :repo_name, :string
14 | field :webhook_id, :integer
15 |
16 | has_many :issue_syncs, IssueSync, foreign_key: :github_internal_webhook_id
17 |
18 | timestamps()
19 | end
20 |
21 | @doc false
22 | def create_changeset(github_webhook, attrs) do
23 | github_webhook
24 | |> cast(attrs, [:repo_id, :repo_owner, :repo_name])
25 | |> validate_required([:repo_id, :repo_owner, :repo_name])
26 | |> unique_constraint([:repo_id])
27 | |> unique_constraint([:repo_owner, :repo_name])
28 | end
29 |
30 | @doc false
31 | def update_changeset(github_webhook, attrs) do
32 | github_webhook
33 | |> cast(attrs, [:webhook_id])
34 | |> validate_required([:webhook_id])
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/linear/webhooks/linear_webhook.ex:
--------------------------------------------------------------------------------
1 | defmodule Linear.Webhooks.LinearWebhook do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Linear.Data.IssueSync
6 |
7 | @primary_key {:id, :binary_id, autogenerate: true}
8 | @foreign_key_type :binary_id
9 |
10 | schema "linear_webhooks" do
11 | field :team_id, :string
12 | field :webhook_id, :string
13 |
14 | has_many :issue_syncs, IssueSync, foreign_key: :linear_internal_webhook_id
15 |
16 | timestamps()
17 | end
18 |
19 | @doc false
20 | def create_changeset(linear_webhook, attrs) do
21 | linear_webhook
22 | |> cast(attrs, [:team_id])
23 | |> validate_required([:team_id])
24 | |> unique_constraint([:team_id])
25 | end
26 |
27 | @doc false
28 | def update_changeset(linear_webhook, attrs) do
29 | linear_webhook
30 | |> cast(attrs, [:webhook_id])
31 | |> validate_required([:webhook_id])
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/linear_web.ex:
--------------------------------------------------------------------------------
1 | defmodule LinearWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use LinearWeb, :controller
9 | use LinearWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(css fonts images js favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | namespace: LinearWeb,
43 | formats: [:html, :json],
44 | layouts: [html: LinearWeb.Layouts]
45 |
46 | import Plug.Conn
47 | import LinearWeb.Gettext
48 |
49 | unquote(verified_routes())
50 | end
51 | end
52 |
53 | def live_view do
54 | quote do
55 | use Phoenix.LiveView,
56 | layout: {LinearWeb.Layouts, :app}
57 |
58 | unquote(html_helpers())
59 | end
60 | end
61 |
62 | def live_component do
63 | quote do
64 | use Phoenix.LiveComponent
65 |
66 | unquote(html_helpers())
67 | end
68 | end
69 |
70 | def html do
71 | quote do
72 | use Phoenix.Component
73 |
74 | # Import convenience functions from controllers
75 | import Phoenix.Controller,
76 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
77 |
78 | # Include general helpers for rendering HTML
79 | unquote(html_helpers())
80 | end
81 | end
82 |
83 | defp html_helpers do
84 | quote do
85 | # HTML escaping functionality
86 | import Phoenix.HTML
87 | import Phoenix.HTML.Form
88 | import Phoenix.HTML.Link
89 | # Core UI components and translation
90 | import LinearWeb.CoreComponents
91 | import LinearWeb.ErrorHelpers
92 | import LinearWeb.Gettext
93 |
94 | # Shortcut for generating JS commands
95 | alias Phoenix.LiveView.JS
96 |
97 | # Routes generation with the ~p sigil
98 | unquote(verified_routes())
99 | end
100 | end
101 |
102 | def verified_routes do
103 | quote do
104 | use Phoenix.VerifiedRoutes,
105 | endpoint: LinearWeb.Endpoint,
106 | router: LinearWeb.Router,
107 | statics: LinearWeb.static_paths()
108 |
109 | alias LinearWeb.Router.Helpers, as: Routes
110 | end
111 | end
112 |
113 | @doc """
114 | When used, dispatch to the appropriate controller/view/etc.
115 | """
116 | defmacro __using__(which) when is_atom(which) do
117 | apply(__MODULE__, which, [])
118 | end
119 | end
120 |
--------------------------------------------------------------------------------
/lib/linear_web/components/layouts.ex:
--------------------------------------------------------------------------------
1 | defmodule LinearWeb.Layouts do
2 | use LinearWeb, :html
3 |
4 | embed_templates "layouts/*"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/linear_web/components/layouts/app.html.heex:
--------------------------------------------------------------------------------
1 |
23 | You don't have any issue syncs set up yet, create one to start syncing your issues between Linear and GitHub. 24 |
25 | <% else %> 26 |12 | LinearSync will use these options to sync issues between GitHub and Linear automatically. 13 |
14 | 15 |9 | LinearSync needs to connect to your GitHub account in order to sync issues 10 | between Linear and GitHub. You will have full control over this process. Or, <%= link( 11 | "go back", 12 | to: Routes.session_path(@socket, :delete), 13 | method: :delete, 14 | class: "underline" 15 | ) %>. 16 |
17 | 18 | <%= if @linking? do %> 19 | 22 | <% else %> 23 | 36 | <% end %> 37 |12 | LinearSync will use these options to sync issues between GitHub and Linear automatically. 13 |
14 | 15 |9 | Note: you may not see all webhooks listed here if you have an older 10 | account. To fix this, please manually disable and re-enable your issue syncs :) 11 |
12 |21 | No Linear webhooks found. 22 |
23 | <% else %> 24 |ID: "<%= linear_webhook.webhook_id %>"
30 |Team: "<%= linear_webhook.team_id %>"
31 |64 | No Github webhooks found. 65 |
66 | <% else %> 67 |ID: <%= github_webhook.webhook_id %>
73 |Repo: <%= github_webhook.repo_owner %>/<%= github_webhook.repo_name %>
74 |4 | Successfully linked your GitHub! 5 |
6 | 9 |28 | LinearSync sends GitHub issues to Linear. 29 |
30 |31 | This lets open source maintainers better integrate community feedback into the Linear Method. 35 |
36 |37 | Made by @jtormey, with the help of Elixir and Phoenix. 38 | View the full source code on GitHub. 42 |
43 |44 | Special thanks to the great folks over at Linear and GitHub for the amazing platforms they've built. 45 |
46 |