├── .dockerignore ├── .editorconfig ├── .github └── workflows │ ├── release-image.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── Dockerfile ├── README.md ├── charts └── gitlab-merger-bot │ ├── Chart.yaml │ ├── templates │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── http-proxy.yaml │ ├── ingress.yml │ ├── route.yml │ └── service.yml │ └── values.yaml ├── common ├── .gitignore └── package.json ├── dashboard ├── .editorconfig ├── .gitignore ├── .prettierignore ├── codegen.yml ├── next-env.d.ts ├── next.config.js ├── package.json ├── src │ ├── app │ │ ├── ApolloWrapper.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── web-hook-history │ │ │ └── page.tsx │ ├── components │ │ ├── layout.tsx │ │ └── ui │ │ │ ├── UserAvatar.tsx │ │ │ └── overlay-loading.tsx │ ├── lib │ │ └── apollo.tsx │ └── theme.ts └── tsconfig.json ├── docs ├── assign.png ├── merged.png └── queue.png ├── package.json ├── schema.graphql ├── server ├── .gitignore ├── .prettierignore ├── codegen.yml ├── codegen │ └── typedefsCodegen.js ├── package.json ├── src │ ├── AssignToAuthor.ts │ ├── BotLabelsSetter.ts │ ├── Config.ts │ ├── GitlabApi.ts │ ├── Job.ts │ ├── MergeRequestAcceptor.ts │ ├── MergeRequestCheckerLoop.ts │ ├── MergeRequestReceiver.ts │ ├── PipelineCanceller.ts │ ├── Queue.ts │ ├── SendNote.ts │ ├── Types.ts │ ├── Utils.ts │ ├── WebHookHistory.ts │ ├── WebHookServer.ts │ ├── Worker.ts │ ├── __tests__ │ │ ├── Queue.ts │ │ ├── WebHookHistory.ts │ │ └── Worker.ts │ └── index.ts ├── tsconfig.json └── tsconfig.webpack-config.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.github 3 | /node_modules 4 | /dashboard/node_modules 5 | /dashboard/.next 6 | /dashboard/out 7 | /server/node_modules 8 | /server/lib 9 | /server/gitlab-merger-bot 10 | /server/src/generated/ 11 | /yarn-error.log 12 | /charts 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | 11 | [*.{yml,yaml,lock}] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.github/workflows/release-image.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Get release version 15 | id: get_version 16 | run: echo "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3 23 | 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v3 26 | with: 27 | username: ${{ secrets.DOCKER_USERNAME }} 28 | password: ${{ secrets.DOCKER_PASSWORD }} 29 | 30 | - name: Build and push 31 | uses: docker/build-push-action@v5 32 | with: 33 | context: . 34 | platforms: linux/amd64,linux/arm64 35 | push: true 36 | tags: "${{ secrets.DOCKER_NAMESPACE }}/gitlab-merger-bot:latest,${{ secrets.DOCKER_NAMESPACE }}/gitlab-merger-bot:${{ env.RELEASE_VERSION }}", 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Charts 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags-ignore: 8 | - v* 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Configure Git 19 | run: | 20 | git config user.name "$GITHUB_ACTOR" 21 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 22 | 23 | - name: Run chart-releaser 24 | uses: helm/chart-releaser-action@v1.6.0 25 | env: 26 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 27 | 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v3 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Login to Docker Hub 35 | uses: docker/login-action@v3 36 | with: 37 | username: ${{ secrets.DOCKER_USERNAME }} 38 | password: ${{ secrets.DOCKER_PASSWORD }} 39 | 40 | - name: Build and push 41 | uses: docker/build-push-action@v5 42 | with: 43 | context: . 44 | platforms: linux/amd64,linux/arm64 45 | push: true 46 | tags: ${{ secrets.DOCKER_NAMESPACE }}/gitlab-merger-bot:latest 47 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [20.x] 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | 19 | - name: Install 20 | run: yarn install --frozen-lockfile && yarn run generate 21 | 22 | - name: Lint 23 | run: yarn run check:cs 24 | 25 | - name: Check TS types 26 | run: yarn run check:types 27 | 28 | - name: Test 29 | run: yarn run check:tests 30 | 31 | - name: Check Helm Chart 32 | id: lint 33 | uses: helm/chart-testing-action@v2.0.1 34 | with: 35 | command: lint 36 | 37 | - name: Create kind cluster 38 | uses: helm/kind-action@v1.1.0 39 | with: 40 | install_local_path_provisioner: true 41 | # Only build a kind cluster if there are chart changes to test. 42 | if: steps.lint.outputs.changed == 'true' 43 | 44 | - name: Check Helm Install 45 | uses: helm/chart-testing-action@v2.0.1 46 | with: 47 | command: install 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.envrc 2 | /node_modules 3 | /yarn-error.log 4 | /gitlab-merger-bot 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.github/ 3 | /charts/ 4 | /dashboard/ 5 | /server/ 6 | /common/ 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | jsxSingleQuote: true, 4 | trailingComma: 'all', 5 | bracketSpacing: true, 6 | arrowParens: 'always', 7 | semi: true, 8 | printWidth: 100, 9 | }; 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM --platform=$BUILDPLATFORM node:20.11.1-alpine AS base 3 | WORKDIR /app 4 | 5 | COPY ./package.json ./yarn.lock ./ 6 | COPY ./server/package.json ./server/ 7 | COPY ./dashboard/package.json ./dashboard/ 8 | COPY ./common/package.json ./common/ 9 | 10 | # We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com 11 | # See, for example https://github.com/yarnpkg/yarn/issues/5540 12 | RUN set -ex \ 13 | && yarn install --network-timeout 300000 14 | 15 | COPY ./schema.graphql ./ 16 | 17 | 18 | FROM --platform=$BUILDPLATFORM base AS server-build 19 | WORKDIR /app/server 20 | 21 | COPY ./server/codegen.yml ./ 22 | COPY ./server/codegen ./codegen/ 23 | 24 | RUN set -ex \ 25 | && yarn run generate 26 | 27 | COPY ./server ./ 28 | 29 | RUN set -ex \ 30 | && yarn run build \ 31 | && yarn run build-bin 32 | 33 | 34 | FROM --platform=$BUILDPLATFORM base AS dashboard-build 35 | WORKDIR /app/dashboard 36 | 37 | COPY ./dashboard ./ 38 | 39 | RUN set -ex \ 40 | # because it needs src 41 | && yarn run generate \ 42 | && yarn run build 43 | 44 | 45 | FROM --platform=$BUILDPLATFORM alpine:3.19.1 46 | WORKDIR /app 47 | CMD ["/app/server/gitlab-merger-bot"] 48 | ENV NODE_ENV=production 49 | 50 | RUN set -ex \ 51 | && apk --no-cache --update add \ 52 | ca-certificates \ 53 | libstdc++ \ 54 | libgcc 55 | 56 | COPY --from=server-build /app/server/gitlab-merger-bot /app/server/ 57 | COPY --from=dashboard-build /app/dashboard/out /app/dashboard/out/ 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitLab merger bot 2 | 3 | ![Build Status](https://github.com/pepakriz/gitlab-merger-bot/actions/workflows/release.yml/badge.svg) 4 | 5 | ## What does it do? 6 | 7 | The goal is to have green master after every merge. To achieve this, you have to rebase every single merge request just before a merge and wait for the pipeline status. It takes a lot of time to manually maintain, especially when you have to process multiple merge requests at once (common situation for large projects, monorepos etc.). So let's automate it with GitLab MergerBot. 8 | 9 | 1) When your merge request is ready to be merged, assign it to the bot. 10 | 2) The bot will add your request to its own serial (FIFO) queue. (single queue for every repository) 11 | 3) When your request is on the turn, the bot will rebase the MR and start waiting for the pipeline. 12 | 4) When the bot detects some problems with the merge request it'll reassign the merge request back to the author.
13 | Reasons can be for example: 14 | - Failing pipeline or pipeline waiting for a manual action 15 | - The merge request has unresolved discussions 16 | - The merge request can't be rebased due to a conflict 17 | 18 |

19 | Merged 20 | Assign 21 | Queue 22 |

23 | 24 | ### Advanced features 25 | 26 | - Autogenerated commit message based on MR title (Suffixed with a link to the original MR). 27 | - Blocking jobs are triggered automatically. Very useful for E2E testing etc. 28 | - Bot skips squashing when the `bot:skip-squash` label is present on a MR. 29 | - Bot assigns a MR to the start of the queue when the `bot:high-priority` label is present. This is useful for hotfixes etc. 30 | 31 | ## Pre-Installation requirements 32 | 33 | #### Get the auth token 34 | 35 | 1) Create a new account for your bot-user 36 | 2) Sign-in to GitLab as the bot-user and go to [https://gitlab.com/profile/personal_access_tokens](https://gitlab.com/profile/personal_access_tokens) 37 | 3) Add new personal access token with the `api` scope 38 | 39 | > We strongly recommend using a separate account for the bot-user. Don't reuse an existing account that can leave the project in the future. 40 | 41 | #### Setup a GitLab repository 42 | 43 | 1) Make sure that your bot-user has privileges to accept merge requests 44 | 2) In the `General Settings - Merge Request` section: 45 | * set `Merge method` to `Fast-forward merge` 46 | * check `Only allow merge requests to be merged if the pipeline succeeds` 47 | * check (optionally) `All discussions must be resolved` 48 | 49 | 50 | ## Usage 51 | 52 | #### Running in kubernetes (with HELM) 53 | 54 | To add the Helm Chart for your local client, run helm repo add: 55 | 56 | ```bash 57 | helm repo add gitlab-merger-bot https://pepakriz.github.io/gitlab-merger-bot 58 | ``` 59 | 60 | And install it: 61 | 62 | ```bash 63 | helm install --name gitlab-merger-bot gitlab-merger-bot \ 64 | # --set settings.gitlabUrl="https://gitlab.mycompany.com" \ 65 | --set settings.authToken="" 66 | ``` 67 | 68 | #### Running in docker 69 | 70 | ```bash 71 | docker run -d --name gitlab-merger-bot --restart on-failure \ 72 | # -e GITLAB_URL="https://gitlab.mycompany.com" \ 73 | -e GITLAB_AUTH_TOKEN="" \ 74 | -v "$(pwd)/data":/data \ 75 | pepakriz/gitlab-merger-bot:latest 76 | ``` 77 | 78 | #### Running as a plain JS app 79 | 80 | ```bash 81 | yarn install 82 | yarn run build 83 | GITLAB_AUTH_TOKEN="" yarn run start 84 | ``` 85 | 86 | #### Configuration options 87 | 88 | | Env variable | Default value | | 89 | |--------------------------------|----------------------|----------------------------------------------------------------------------| 90 | | `GITLAB_URL` | `https://gitlab.com` | GitLab instance URL | 91 | | `GITLAB_AUTH_TOKEN` | | `required` Your GitLab token | 92 | | `ALLOWED_PROJECT_IDS` | `` | It'll restrict operation only on selected projects. (comma separated list) | 93 | | `HTTP_PROXY` | `` | Use HTTP proxy for API communication | 94 | | `CI_CHECK_INTERVAL` | `10` | Time between CI checks (in seconds) | 95 | | `MR_CHECK_INTERVAL` | `20` | Time between merge-requests checks (in seconds) | 96 | | `REMOVE_BRANCH_AFTER_MERGE` | `true` | It'll remove branch after merge | 97 | | `SQUASH_MERGE_REQUEST` | `true` | It'll squash commits on merge | 98 | | `PREFER_GITLAB_TEMPLATE` | `false` | Use Gitlab template instead of custom message | 99 | | `AUTORUN_MANUAL_BLOCKING_JOBS` | `true` | It'll autorun manual blocking jobs before merge | 100 | | `SKIP_SQUASHING_LABEL` | `bot:skip-squash` | It'll skip squash when MR contains this label | 101 | | `HIGH_PRIORITY_LABEL` | `bot:high-priority` | It'll put MR with this label to the beginning of the queue | 102 | | `SENTRY_DSN` | `` | It'll enable Sentry monitoring | 103 | | `HTTP_SERVER_ENABLE` | `false` | It'll enable experimental API and dashboard support | 104 | | `HTTP_SERVER_PORT` | `4000` | It'll use different http server port | 105 | | `WEB_HOOK_TOKEN` | `` | It'll enable experimental web hook support | 106 | | `WEB_HOOK_HISTORY_SIZE` | `100` | It's useful just primarily for debugging purposes. | 107 | | `ENABLE_PERMISSION_VALIDATION` | `false` | It'll enable experimental permission validation | 108 | 109 | ## Development 110 | 111 | For web hook development use this: 112 | 113 | ```bash 114 | export NGROK_AUTHTOKEN= 115 | docker run -it --rm --net=host -p 4040:4040 -e NGROK_AUTHTOKEN="$NGROK_AUTHTOKEN" wernight/ngrok ngrok http 4000 116 | ``` 117 | -------------------------------------------------------------------------------- /charts/gitlab-merger-bot/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | appVersion: "2.0.0-beta13" 3 | description: A Helm chart for Kubernetes 4 | name: gitlab-merger-bot 5 | version: 2.0.0-beta14 6 | home: https://github.com/pepakriz/gitlab-merger-bot 7 | maintainers: 8 | - name: pepakriz 9 | email: pepakriz@gmail.com 10 | -------------------------------------------------------------------------------- /charts/gitlab-merger-bot/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "gitlab-merger-bot.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "gitlab-merger-bot.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "gitlab-merger-bot.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "helm-chart.labels" -}} 38 | app: {{ include "gitlab-merger-bot.name" . }} 39 | chart: {{ include "gitlab-merger-bot.chart" . }} 40 | heritage: {{ .Release.Service }} 41 | release: {{ .Release.Name }} 42 | {{- end }} 43 | 44 | 45 | {{/* 46 | Allow the release namespace to be overridden for multi-namespace deployments in combined charts 47 | */}} 48 | {{- define "gitlab-merger-bot.namespace" -}} 49 | {{- if .Values.namespaceOverride -}} 50 | {{- .Values.namespaceOverride -}} 51 | {{- else -}} 52 | {{- .Release.Namespace -}} 53 | {{- end -}} 54 | {{- end -}} 55 | -------------------------------------------------------------------------------- /charts/gitlab-merger-bot/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "gitlab-merger-bot.fullname" . }} 5 | namespace: {{ include "gitlab-merger-bot.namespace" . }} 6 | {{- if .Values.annotations }} 7 | annotations: 8 | {{ toYaml .Values.annotations | indent 4 }} 9 | {{- end }} 10 | labels: 11 | {{- include "helm-chart.labels" . | nindent 4 }} 12 | {{- if .Values.labels }} 13 | {{ toYaml .Values.labels | indent 4 }} 14 | {{- end }} 15 | spec: 16 | replicas: {{ .Values.replicaCount }} 17 | selector: 18 | matchLabels: 19 | app: {{ include "gitlab-merger-bot.name" . }} 20 | release: {{ .Release.Name }} 21 | strategy: 22 | type: Recreate # stop app before update 23 | template: 24 | metadata: 25 | {{- if .Values.podAnnotations }} 26 | annotations: 27 | {{ toYaml .Values.podAnnotations | indent 8 }} 28 | {{- end }} 29 | labels: 30 | app: {{ include "gitlab-merger-bot.name" . }} 31 | release: {{ .Release.Name }} 32 | spec: 33 | containers: 34 | - name: {{ .Chart.Name }} 35 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 36 | imagePullPolicy: {{ .Values.image.pullPolicy }} 37 | env: 38 | - name: GITLAB_AUTH_TOKEN 39 | value: "{{ .Values.settings.authToken }}" 40 | - name: GITLAB_URL 41 | value: "{{ .Values.settings.gitlabUrl }}" 42 | - name: ALLOWED_PROJECT_IDS 43 | value: "{{ .Values.settings.allowedProjectIds }}" 44 | - name: CI_CHECK_INTERVAL 45 | value: "{{ .Values.settings.ciCheckInterval }}" 46 | - name: MR_CHECK_INTERVAL 47 | value: "{{ .Values.settings.mrCheckInterval }}" 48 | - name: REMOVE_BRANCH_AFTER_MERGE 49 | value: "{{ .Values.settings.removeBranchAfterMerge }}" 50 | - name: SQUASH_MERGE_REQUEST 51 | value: "{{ .Values.settings.squashMergeRequest }}" 52 | - name: AUTORUN_MANUAL_BLOCKING_JOBS 53 | value: "{{ .Values.settings.autorunManualBlockingJobs }}" 54 | - name: SENTRY_DSN 55 | value: "{{ .Values.settings.sentryDsn }}" 56 | - name: SKIP_SQUASHING_LABEL 57 | value: "{{ .Values.settings.skipSquashingLabel }}" 58 | - name: HIGH_PRIORITY_LABEL 59 | value: "{{ .Values.settings.highPriorityLabel }}" 60 | - name: HTTP_SERVER_ENABLE 61 | value: "{{ .Values.settings.httpServerEnable }}" 62 | - name: HTTP_SERVER_PORT 63 | value: "{{ .Values.settings.httpServerPort }}" 64 | - name: WEB_HOOK_TOKEN 65 | value: "{{ .Values.settings.webHookToken }}" 66 | - name: WEB_HOOK_HISTORY_SIZE 67 | value: "{{ .Values.settings.webHookHistorySize }}" 68 | - name: ENABLE_PERMISSION_VALIDATION 69 | value: "{{ .Values.settings.enablePermissionValidation }}" 70 | {{- if .Values.settings.httpProxy }} 71 | - name: HTTP_PROXY 72 | valueFrom: 73 | secretKeyRef: 74 | key: httpProxy 75 | name: {{ .Values.settings.httpProxySecretName }} 76 | {{- end }} 77 | {{- range $key, $value := .Values.env }} 78 | - name: "{{ $key }}" 79 | value: "{{ $value }}" 80 | {{- end }} 81 | resources: 82 | {{ toYaml .Values.resources | indent 10 }} 83 | {{- if .Values.nodeSelector }} 84 | nodeSelector: 85 | {{ toYaml .Values.nodeSelector | indent 8 }} 86 | {{- end }} 87 | {{- if .Values.tolerations }} 88 | tolerations: 89 | {{ toYaml .Values.tolerations | indent 8 }} 90 | {{- end }} 91 | {{- if .Values.affinity }} 92 | affinity: 93 | {{ toYaml .Values.affinity | indent 8 }} 94 | {{- end }} 95 | -------------------------------------------------------------------------------- /charts/gitlab-merger-bot/templates/http-proxy.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.settings.httpProxy}} 2 | Kind: Secret 3 | metadata: 4 | name: {{ .Values.settings.httpProxySecretName }} 5 | namespace: {{ include "gitlab-merger-bot.namespace" . }} 6 | type: Opaque 7 | data: 8 | httpProxy: {{ .Values.settings.httpProxy | b64enc }} 9 | 10 | {{- end }} 11 | -------------------------------------------------------------------------------- /charts/gitlab-merger-bot/templates/ingress.yml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled }} 2 | {{- $fullName := include "gitlab-merger-bot.fullname" . -}} 3 | {{- $servicePort := .Values.service.port -}} 4 | {{- $ingressPath := .Values.ingress.path -}} 5 | {{- $extraPaths := .Values.ingress.extraPaths -}} 6 | apiVersion: extensions/v1beta1 7 | kind: Ingress 8 | metadata: 9 | name: {{ $fullName }} 10 | namespace: {{ include "gitlab-merger-bot.namespace" . }} 11 | {{- if .Values.ingress.annotations }} 12 | annotations: 13 | {{ toYaml .Values.ingress.annotations | indent 4 }} 14 | {{- end }} 15 | labels: 16 | {{- include "helm-chart.labels" . | nindent 4 }} 17 | {{- if .Values.ingress.labels }} 18 | {{ toYaml .Values.ingress.labels | indent 4 }} 19 | {{- end }} 20 | spec: 21 | {{- if .Values.ingress.tls }} 22 | tls: 23 | {{ toYaml .Values.ingress.tls | indent 4 }} 24 | {{- end }} 25 | rules: 26 | {{- range .Values.ingress.hosts }} 27 | - host: {{ . }} 28 | http: 29 | paths: 30 | {{ if $extraPaths }} 31 | {{ toYaml $extraPaths | indent 10 }} 32 | {{- end }} 33 | - path: {{ $ingressPath }} 34 | backend: 35 | serviceName: {{ $fullName }} 36 | servicePort: {{ $servicePort }} 37 | {{- end }} 38 | {{- end }} 39 | -------------------------------------------------------------------------------- /charts/gitlab-merger-bot/templates/route.yml: -------------------------------------------------------------------------------- 1 | {{- if .Values.route.enabled }} 2 | {{- $fullName := include "gitlab-merger-bot.fullname" . -}} 3 | {{- $service := .Values.service -}} 4 | {{- $route := .Values.route -}} 5 | apiVersion: route.openshift.io/v1 6 | kind: Route 7 | metadata: 8 | name: {{ $fullName }} 9 | namespace: {{ include "gitlab-merger-bot.namespace" . }} 10 | {{- if $route.annotations }} 11 | annotations: 12 | {{ toYaml $route.annotations | indent 4 }} 13 | {{- end }} 14 | labels: 15 | {{- include "helm-chart.labels" . | nindent 4 }} 16 | {{- if $route.labels }} 17 | {{ toYaml $route.labels | indent 4 }} 18 | {{- end }} 19 | spec: 20 | host: {{ $route.host }} 21 | path: {{ $route.path }} 22 | port: 23 | targetPort: {{ $service.port }} 24 | to: 25 | kind: Service 26 | name: {{ $fullName }} 27 | weight: 100 28 | {{- if $route.tls.enabled }} 29 | tls: 30 | {{ toYaml $route.tls.config | nindent 4 }} 31 | {{- end}} 32 | {{- end}} 33 | -------------------------------------------------------------------------------- /charts/gitlab-merger-bot/templates/service.yml: -------------------------------------------------------------------------------- 1 | {{- if or .Values.ingress.enabled .Values.route.enabled }} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ include "gitlab-merger-bot.fullname" . }} 6 | namespace: {{ include "gitlab-merger-bot.namespace" . }} 7 | {{- if .Values.service.annotations }} 8 | annotations: 9 | {{ toYaml .Values.service.annotations | indent 4 }} 10 | {{- end }} 11 | labels: 12 | {{- include "helm-chart.labels" . | nindent 4 }} 13 | {{- if .Values.service.labels }} 14 | {{ toYaml .Values.service.labels | indent 4 }} 15 | {{- end }} 16 | spec: 17 | {{- if (or (eq .Values.service.type "ClusterIP") (empty .Values.service.type)) }} 18 | type: ClusterIP 19 | {{- if .Values.service.clusterIP }} 20 | clusterIP: {{ .Values.service.clusterIP }} 21 | {{end}} 22 | {{- else if eq .Values.service.type "LoadBalancer" }} 23 | type: {{ .Values.service.type }} 24 | {{- if .Values.service.loadBalancerIP }} 25 | loadBalancerIP: {{ .Values.service.loadBalancerIP }} 26 | {{- end }} 27 | {{- if .Values.service.loadBalancerSourceRanges }} 28 | loadBalancerSourceRanges: 29 | {{ toYaml .Values.service.loadBalancerSourceRanges | indent 4 }} 30 | {{- end -}} 31 | {{- else }} 32 | type: {{ .Values.service.type }} 33 | {{- end }} 34 | {{- if .Values.service.externalIPs }} 35 | externalIPs: 36 | {{ toYaml .Values.service.externalIPs | indent 4 }} 37 | {{- end }} 38 | ports: 39 | - name: {{ .Values.service.portName }} 40 | port: {{ .Values.service.port }} 41 | protocol: TCP 42 | targetPort: {{ .Values.service.targetPort }} 43 | {{ if (and (eq .Values.service.type "NodePort") (not (empty .Values.service.nodePort))) }} 44 | nodePort: {{.Values.service.nodePort}} 45 | {{ end }} 46 | selector: 47 | app: {{ include "gitlab-merger-bot.name" . }} 48 | release: {{ .Release.Name }} 49 | {{- end -}} 50 | -------------------------------------------------------------------------------- /charts/gitlab-merger-bot/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | restartPolicy: Never 3 | 4 | image: 5 | repository: pepakriz/gitlab-merger-bot 6 | tag: v2.0.0-beta13 7 | pullPolicy: IfNotPresent 8 | 9 | resources: 10 | requests: 11 | cpu: 50m 12 | memory: 192Mi 13 | limits: 14 | cpu: 150m 15 | memory: 192Mi 16 | 17 | annotations: {} 18 | labels: {} 19 | nodeSelector: {} 20 | tolerations: [] 21 | podAnnotations: {} 22 | env: {} 23 | 24 | ingress: 25 | enabled: false 26 | annotations: {} 27 | # kubernetes.io/ingress.class: nginx 28 | # kubernetes.io/tls-acme: "true" 29 | labels: {} 30 | path: / 31 | hosts: 32 | - chart-example.local 33 | ## Extra paths to prepend to every host configuration. This is useful when working with annotation based services. 34 | extraPaths: [] 35 | # - path: /* 36 | # backend: 37 | # serviceName: ssl-redirect 38 | # servicePort: use-annotation 39 | tls: [] 40 | # - secretName: chart-example-tls 41 | # hosts: 42 | # - chart-example.local 43 | 44 | route: 45 | enabled: false 46 | annotations: {} 47 | # kubernetes.io/ingress.class: nginx 48 | # kubernetes.io/tls-acme: "true" 49 | labels: {} 50 | # -- The hostname that should be used. 51 | # If left empty, OpenShift will generate one for you with defaults. 52 | host: "chart-example.local" 53 | # -- Subpath of the route. 54 | path: / 55 | tls: 56 | # If `true`, TLS is enabled for the Route 57 | enabled: true 58 | config: 59 | # Insecure edge termination policy of the Route. Can be `None`, `Redirect`, or `Allow` 60 | insecureEdgeTerminationPolicy: Redirect 61 | # TLS termination of the route. Can be `edge`, `passthrough`, or `reencrypt` 62 | termination: edge 63 | 64 | service: 65 | type: ClusterIP 66 | port: 4000 67 | targetPort: 4000 68 | nodePort: 30044 69 | annotations: {} 70 | labels: {} 71 | portName: service 72 | clusterIP: "" 73 | externalIPs: [] 74 | loadBalancerIP: "" 75 | loadBalancerSourceRanges: [] 76 | 77 | settings: 78 | gitlabUrl: "https://gitlab.com" 79 | authToken: "" 80 | sentryDsn: "" 81 | allowedProjectIds: "" 82 | ciCheckInterval: 10 83 | mrCheckInterval: 20 84 | removeBranchAfterMerge: true 85 | squashMergeRequest: true 86 | autorunManualBlockingJobs: true 87 | skipSquashingLabel: "" 88 | highPriorityLabel: "" 89 | httpServerEnable: false 90 | httpServerPort: 4000 91 | webHookToken: "" 92 | webHookHistorySize: 100 93 | httpProxySecretName: http-proxy 94 | httpProxy: "" 95 | enablePermissionValidation: false 96 | -------------------------------------------------------------------------------- /common/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gitlab-merger-bot/common", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "format": "prettier --write \"**/*.{js,json,ts,tsx}\"", 7 | "generate": "true", 8 | "check": "yarn run check:cs", 9 | "check:cs": "prettier --check \"**/*.{js,json,ts,tsx}\"", 10 | "check:types": "true", 11 | "check:tests": "true" 12 | }, 13 | "devDependencies": { 14 | "husky": "9.0.11", 15 | "prettier": "3.2.5", 16 | "pretty-quick": "4.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /dashboard/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | max_line_length = 120 11 | 12 | [*.{yml,sh,df,lock}] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /dashboard/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.next/ 3 | /.serverless/ 4 | /out/ 5 | /src/types.ts 6 | -------------------------------------------------------------------------------- /dashboard/.prettierignore: -------------------------------------------------------------------------------- 1 | /.next/ 2 | /out/ 3 | -------------------------------------------------------------------------------- /dashboard/codegen.yml: -------------------------------------------------------------------------------- 1 | config: 2 | avoidOptionals: 3 | inputValue: false 4 | object: true 5 | preResolveTypes: true 6 | skipTypename: true 7 | immutableTypes: true 8 | scalars: 9 | Uuid: Uuid 10 | CheckSeverity: number 11 | CheckImpact: number 12 | 13 | generates: 14 | ./src/types.ts: 15 | schema: ../schema.graphql 16 | documents: "src/**/*.{ts,tsx}" 17 | plugins: 18 | - typescript 19 | - typescript-operations 20 | - typescript-react-apollo 21 | hooks: 22 | afterOneFileWrite: 23 | - prettier --write 24 | -------------------------------------------------------------------------------- /dashboard/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /dashboard/next.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | env: { 6 | API_URL: process.env.API_URL, 7 | WS_URL: process.env.WS_URL, 8 | }, 9 | output: 'export', 10 | experimental: { 11 | typedRoutes: true, 12 | }, 13 | typescript: { 14 | // We can ignore it because we have another job in the pipeline for static analysis 15 | ignoreBuildErrors: true, 16 | }, 17 | }; 18 | 19 | module.exports = nextConfig; 20 | -------------------------------------------------------------------------------- /dashboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gitlab-merger-bot/dashboard", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start:dev": "next --turbo", 7 | "start:local": "API_URL=http://127.0.0.1:4000/graphql WS_URL=ws://127.0.0.1:4000/graphql next --turbo", 8 | "build": "next build", 9 | "start": "next start", 10 | "generate": "graphql-codegen", 11 | "format": "prettier --write \"**/*.{js,json,ts,tsx}\"", 12 | "check": "yarn run check:types", 13 | "check:cs": "prettier --check \"**/*.{js,json,ts,tsx}\"", 14 | "check:types": "tsc --noEmit", 15 | "check:tests": "true" 16 | }, 17 | "dependencies": { 18 | "@apollo/client": "3.9.9", 19 | "@emotion/cache": "11.11.0", 20 | "@emotion/react": "11.11.4", 21 | "@emotion/server": "11.11.0", 22 | "@emotion/styled": "11.11.0", 23 | "@gitlab-merger-bot/common": "*", 24 | "@gitlab-merger-bot/server": "*", 25 | "@mui/icons-material": "5.15.14", 26 | "@mui/material": "5.15.14", 27 | "@mui/material-nextjs": "5.15.11", 28 | "graphql-tag": "2.12.6", 29 | "graphql-ws": "^5.15.0", 30 | "next": "14.1.4", 31 | "react": "18.2.0", 32 | "react-dom": "18.2.0" 33 | }, 34 | "devDependencies": { 35 | "@graphql-codegen/cli": "5.0.2", 36 | "@graphql-codegen/typescript": "4.0.6", 37 | "@graphql-codegen/typescript-operations": "4.2.0", 38 | "@graphql-codegen/typescript-react-apollo": "4.3.0", 39 | "@types/node": "20.11.30", 40 | "@types/react": "18.2.69", 41 | "graphql": "15.8.0", 42 | "typescript": "5.4.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /dashboard/src/app/ApolloWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useState } from 'react'; 4 | import { initApolloClient } from '../lib/apollo'; 5 | import { ApolloProvider } from '@apollo/client'; 6 | 7 | export const ApolloWrapper = ({ children }: { children: React.ReactNode }) => { 8 | const [client] = useState(initApolloClient); 9 | 10 | return {children}; 11 | }; 12 | -------------------------------------------------------------------------------- /dashboard/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Metadata } from 'next'; 3 | import theme from '../theme'; 4 | import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter'; 5 | import { ThemeProvider } from '@mui/material/styles'; 6 | import CssBaseline from '@mui/material/CssBaseline'; 7 | import { Layout } from '../components/layout'; 8 | import { ApolloWrapper } from './ApolloWrapper'; 9 | 10 | export const metadata: Metadata = { 11 | title: 'GitLab Merger Bot', 12 | description: 'Welcome to Next.js', 13 | }; 14 | 15 | export default function RootLayout({ children }: { children: React.ReactElement }) { 16 | return ( 17 | 18 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {children} 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /dashboard/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import Typography from '@mui/material/Typography'; 5 | import Box from '@mui/material/Box'; 6 | import { useMutation, useSubscription } from '@apollo/client'; 7 | import gql from 'graphql-tag'; 8 | import OverlayLoading from '../components/ui/overlay-loading'; 9 | import { 10 | GetQueuesSubscriptionJobFragment, 11 | GetQueuesSubscriptionSubscription, 12 | UnassignMutation, 13 | UnassignMutationVariables, 14 | } from '../types'; 15 | import Table from '@mui/material/Table'; 16 | import TableBody from '@mui/material/TableBody'; 17 | import TableCell from '@mui/material/TableCell'; 18 | import TableContainer from '@mui/material/TableContainer'; 19 | import TableHead from '@mui/material/TableHead'; 20 | import TableRow from '@mui/material/TableRow'; 21 | import Paper from '@mui/material/Paper'; 22 | import Button from '@mui/material/Button'; 23 | import ButtonGroup from '@mui/material/ButtonGroup'; 24 | import Toolbar from '@mui/material/Toolbar'; 25 | import Icon from '@mui/icons-material/Send'; 26 | import { UserAvatar } from '../components/ui/UserAvatar'; 27 | import CircularProgress from '@mui/material/CircularProgress'; 28 | 29 | const Row = (props: GetQueuesSubscriptionJobFragment) => { 30 | const [unassign, { loading }] = useMutation(gql` 31 | mutation Unassign($input: UnassignInput!) { 32 | unassign(input: $input) 33 | } 34 | `); 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | {props.info.mergeRequest.iid}: {props.info.mergeRequest.title} 43 | 44 | 45 | {props.priority} 46 | {props.status} 47 | 48 | 49 | 71 | 79 | 80 | 81 | 82 | ); 83 | }; 84 | 85 | export default function Page() { 86 | const { loading, error, data } = useSubscription(gql` 87 | fragment GetQueuesSubscriptionJob on Job { 88 | status 89 | priority 90 | info { 91 | mergeRequest { 92 | iid 93 | projectId 94 | authorId 95 | title 96 | webUrl 97 | } 98 | } 99 | } 100 | 101 | subscription GetQueuesSubscription { 102 | queues { 103 | name 104 | info { 105 | projectName 106 | } 107 | jobs { 108 | ...GetQueuesSubscriptionJob 109 | } 110 | } 111 | } 112 | `); 113 | 114 | return ( 115 | <> 116 | Queues 117 | 118 | {loading ? ( 119 | 120 | ) : ( 121 | data?.queues.map((queue) => ( 122 | 123 | 124 | 125 | {queue.info.projectName} (ID: {queue.name}) 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | Title 135 | Priority 136 | Status 137 | Actions 138 | 139 | 140 | 141 | {queue.jobs.map((props) => ( 142 | 143 | ))} 144 | 145 |
146 |
147 |
148 | )) 149 | )} 150 |
151 | 152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /dashboard/src/app/web-hook-history/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import Typography from '@mui/material/Typography'; 5 | import Box from '@mui/material/Box'; 6 | import { useSubscription } from '@apollo/client'; 7 | import gql from 'graphql-tag'; 8 | import OverlayLoading from '../../components/ui/overlay-loading'; 9 | import { GetWebHookHistorySubscriptionSubscription } from '../../types'; 10 | import Table from '@mui/material/Table'; 11 | import TableBody from '@mui/material/TableBody'; 12 | import TableCell from '@mui/material/TableCell'; 13 | import TableContainer from '@mui/material/TableContainer'; 14 | import TableHead from '@mui/material/TableHead'; 15 | import TableRow from '@mui/material/TableRow'; 16 | import Paper from '@mui/material/Paper'; 17 | import Button from '@mui/material/Button'; 18 | import { Collapse } from '@mui/material'; 19 | import { KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material'; 20 | 21 | const intl = new Intl.DateTimeFormat(undefined, { 22 | year: 'numeric', 23 | month: 'numeric', 24 | day: 'numeric', 25 | hour: 'numeric', 26 | minute: 'numeric', 27 | second: 'numeric', 28 | }); 29 | 30 | const Row = ({ 31 | webHookHistory, 32 | }: { 33 | webHookHistory: GetWebHookHistorySubscriptionSubscription['webHookHistory'][number]; 34 | }) => { 35 | const [open, setOpen] = React.useState(false); 36 | 37 | return ( 38 | <> 39 | *': { borderBottom: 'unset' } }}> 40 | 41 | {intl.format(new Date(webHookHistory.createdAt * 1000))} 42 | 43 | {webHookHistory.status} 44 | {webHookHistory.event} 45 | 46 | {webHookHistory.data && webHookHistory.data.substr(0, 50)} 47 | 48 | 49 | 52 | 53 | 54 | 55 | 59 | 60 | {webHookHistory.data && ( 61 | 62 |
 63 | 									{JSON.stringify(JSON.parse(webHookHistory.data), null, 2)}
 64 | 								
65 |
66 | )} 67 |
68 |
69 |
70 | 71 | ); 72 | }; 73 | 74 | export default function Page() { 75 | const { loading, error, data } = useSubscription(gql` 76 | subscription GetWebHookHistorySubscription { 77 | webHookHistory { 78 | id 79 | createdAt 80 | data 81 | event 82 | status 83 | } 84 | } 85 | `); 86 | 87 | return ( 88 | <> 89 | Web Hook History 90 | 91 | {loading ? ( 92 | 93 | ) : ( 94 | 95 | 96 | 97 | 98 | Created at 99 | Status 100 | Event 101 | Data 102 | 103 | 104 | 105 | 106 | {data?.webHookHistory.map((webHookHistory) => ( 107 | 108 | ))} 109 | 110 |
111 |
112 | )} 113 |
114 | 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /dashboard/src/components/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import AppBar from '@mui/material/AppBar'; 4 | import Toolbar from '@mui/material/Toolbar'; 5 | import Typography from '@mui/material/Typography'; 6 | import Box from '@mui/material/Box'; 7 | import React from 'react'; 8 | import Container from '@mui/material/Container'; 9 | import Avatar from '@mui/material/Avatar'; 10 | import { useQuery } from '@apollo/client'; 11 | import { MeQuery } from '../types'; 12 | import gql from 'graphql-tag'; 13 | import { Tab, Tabs } from '@mui/material'; 14 | import { usePathname, useRouter } from 'next/navigation'; 15 | import { Route } from 'next'; 16 | 17 | const pages = { 18 | '/': { 19 | label: 'Merge Queue', 20 | }, 21 | '/web-hook-history': { 22 | label: 'Web Hook History', 23 | }, 24 | } satisfies Record; 25 | 26 | export const Layout = ({ children }: { children: React.ReactElement }) => { 27 | const pathname = usePathname(); 28 | const router = useRouter(); 29 | const { data } = useQuery(gql` 30 | query Me { 31 | me { 32 | name 33 | avatarUrl 34 | } 35 | } 36 | `); 37 | 38 | const tabIndex = Object.keys(pages).findIndex((path) => pathname === path); 39 | 40 | return ( 41 | <> 42 | 43 | 44 | 45 |     46 | {data?.me.name} 47 | {}} 50 | textColor='inherit' 51 | sx={{ 52 | px: 6, 53 | '& .MuiTabs-indicator': { 54 | backgroundColor: '#ffffff', 55 | }, 56 | }} 57 | > 58 | {Object.entries(pages).map(([path, { label }]) => ( 59 | router.push(path)} /> 60 | ))} 61 | 62 | 63 | 64 | 65 | {children} 66 | 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/UserAvatar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useQuery } from '@apollo/client'; 4 | import { AvatarQuery, AvatarQueryVariables } from '../../types'; 5 | import gql from 'graphql-tag'; 6 | import Avatar from '@mui/material/Avatar'; 7 | 8 | interface Props { 9 | userId: number; 10 | } 11 | 12 | export const UserAvatar = (props: Props) => { 13 | const { data } = useQuery( 14 | gql` 15 | query Avatar($userId: Int!) { 16 | user(input: { id: $userId }) { 17 | name 18 | avatarUrl 19 | } 20 | } 21 | `, 22 | { 23 | variables: { 24 | userId: props.userId, 25 | }, 26 | fetchPolicy: 'cache-and-network', 27 | }, 28 | ); 29 | 30 | return ; 31 | }; 32 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/overlay-loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import CircularProgress from '@mui/material/CircularProgress'; 4 | import Backdrop from '@mui/material/Backdrop'; 5 | import { Theme } from '@mui/material'; 6 | 7 | const OverlayLoading = () => { 8 | return ( 9 | theme.zIndex.appBar - 1, 13 | color: '#fff', 14 | }} 15 | > 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default OverlayLoading; 22 | -------------------------------------------------------------------------------- /dashboard/src/lib/apollo.tsx: -------------------------------------------------------------------------------- 1 | import { createClient } from 'graphql-ws'; 2 | import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; 3 | 4 | import { ApolloClient, InMemoryCache, NormalizedCacheObject, split } from '@apollo/client'; 5 | import { getMainDefinition } from '@apollo/client/utilities'; 6 | import { HttpLink } from '@apollo/client/link/http'; 7 | 8 | type TApolloClient = ApolloClient; 9 | 10 | let globalApolloClient: TApolloClient; 11 | 12 | export function initApolloClient(initialState?: {}) { 13 | if (typeof window === 'undefined') { 14 | return createApolloClient(initialState); 15 | } 16 | 17 | if (!globalApolloClient) { 18 | globalApolloClient = createApolloClient(initialState); 19 | } 20 | 21 | return globalApolloClient; 22 | } 23 | 24 | export function createApolloClient(initialState = {}) { 25 | const ssrMode = typeof window === 'undefined'; 26 | const cache = new InMemoryCache().restore(initialState); 27 | 28 | let apiUrl = process.env.API_URL; 29 | if (apiUrl === undefined && typeof window !== 'undefined') { 30 | apiUrl = `${window.location.protocol}//${window.location.host}/graphql`; 31 | } 32 | 33 | const httpLink = new HttpLink({ 34 | uri: apiUrl, 35 | credentials: 'same-origin', 36 | }); 37 | 38 | if (!ssrMode) { 39 | let wsUrl = process.env.WS_URL; 40 | if (wsUrl === undefined) { 41 | wsUrl = `${window.location.protocol === 'http:' ? 'ws' : 'wss'}://${ 42 | window.location.host 43 | }/graphql`; 44 | } 45 | 46 | const wsLink = new GraphQLWsLink( 47 | createClient({ 48 | url: wsUrl, 49 | }), 50 | ); 51 | 52 | const link = split( 53 | // split based on operation type 54 | ({ query }) => { 55 | const definition = getMainDefinition(query); 56 | return ( 57 | definition.kind === 'OperationDefinition' && 58 | definition.operation === 'subscription' 59 | ); 60 | }, 61 | wsLink, 62 | httpLink, 63 | ); 64 | 65 | return new ApolloClient({ 66 | ssrMode, 67 | link, 68 | cache, 69 | }); 70 | } 71 | 72 | return new ApolloClient({ 73 | ssrMode, 74 | link: httpLink, 75 | cache, 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /dashboard/src/theme.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createTheme } from '@mui/material/styles'; 4 | import { Roboto } from 'next/font/google'; 5 | 6 | const roboto = Roboto({ 7 | weight: ['300', '400', '500', '700'], 8 | subsets: ['latin'], 9 | display: 'swap', 10 | }); 11 | 12 | const theme = createTheme({ 13 | typography: { 14 | fontFamily: roboto.style.fontFamily, 15 | }, 16 | palette: { 17 | primary: { 18 | main: '#414e9c', 19 | }, 20 | }, 21 | }); 22 | 23 | export default theme; 24 | -------------------------------------------------------------------------------- /dashboard/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "typeRoots": ["./node_modules/@types"], 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ] 23 | }, 24 | "exclude": ["node_modules"], 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /docs/assign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pepakriz/gitlab-merger-bot/575c06f28b00846eb5767c0854854970921c5689/docs/assign.png -------------------------------------------------------------------------------- /docs/merged.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pepakriz/gitlab-merger-bot/575c06f28b00846eb5767c0854854970921c5689/docs/merged.png -------------------------------------------------------------------------------- /docs/queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pepakriz/gitlab-merger-bot/575c06f28b00846eb5767c0854854970921c5689/docs/queue.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlab-merger-bot", 3 | "description": "GitLab FF merger bot", 4 | "license": "MIT", 5 | "private": true, 6 | "workspaces": [ 7 | "common", 8 | "server", 9 | "dashboard" 10 | ], 11 | "scripts": { 12 | "format": "prettier --write \"**/*.{js,json,ts,tsx}\" && yarn workspaces run format", 13 | "generate": "yarn workspaces run generate", 14 | "check": "yarn run check:cs && yarn run check:types && yarn run check:tests", 15 | "check:cs": "prettier --check \"**/*.{js,json,ts,tsx}\" && yarn workspaces run check:cs", 16 | "check:types": "yarn workspaces run check:types", 17 | "check:tests": "yarn workspaces run check:tests" 18 | }, 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "pretty-quick --staged --pattern \"**/*.*(js|json|ts|tsx)\"" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | type JobInfoMergeRequest { 2 | iid: Int! 3 | projectId: Int! 4 | title: String! 5 | webUrl: String! 6 | authorId: Int! 7 | } 8 | 9 | type JobInfo { 10 | mergeRequest: JobInfoMergeRequest! 11 | } 12 | 13 | enum JobPriority { 14 | HIGH 15 | NORMAL 16 | } 17 | 18 | enum WebHookHistoryStatus { 19 | SKIPPED 20 | SUCCESS 21 | UNAUTHORIZED 22 | INVALID_EVENT 23 | } 24 | 25 | enum JobStatus { 26 | IN_PROGRESS 27 | REBASING 28 | CHECKING_MERGE_STATUS 29 | WAITING 30 | WAITING_FOR_CI 31 | } 32 | 33 | type Job { 34 | priority: JobPriority! 35 | status: JobStatus! 36 | info: JobInfo! 37 | } 38 | 39 | type QueueInfo { 40 | projectName: String! 41 | } 42 | 43 | type Queue { 44 | name: String! 45 | info: QueueInfo! 46 | jobs: [Job!]! 47 | } 48 | 49 | type WebHookHistoryRequest { 50 | id: String! 51 | createdAt: Int! 52 | status: WebHookHistoryStatus! 53 | event: String 54 | data: String 55 | } 56 | 57 | type User { 58 | id: Int! 59 | name: String! 60 | username: String! 61 | email: String! 62 | webUrl: String! 63 | avatarUrl: String! 64 | } 65 | 66 | input UserInput { 67 | id: Int! 68 | } 69 | 70 | type Query { 71 | me: User! 72 | user(input: UserInput!): User 73 | queues: [Queue!]! 74 | } 75 | 76 | type Subscription { 77 | queues: [Queue!]! 78 | webHookHistory: [WebHookHistoryRequest!]! 79 | } 80 | 81 | input UnassignInput { 82 | projectId: Int! 83 | mergeRequestIid: Int! 84 | } 85 | 86 | type Mutation { 87 | unassign( 88 | input: UnassignInput! 89 | ): Int 90 | } 91 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib/ 3 | /gitlab-merger-bot 4 | /src/generated/ 5 | -------------------------------------------------------------------------------- /server/.prettierignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | -------------------------------------------------------------------------------- /server/codegen.yml: -------------------------------------------------------------------------------- 1 | config: 2 | useIndexSignature: true 3 | namingConvention: 4 | enumValues: upper-case#upperCase 5 | scalars: 6 | Uuid: Uuid 7 | CheckSeverity: number 8 | CheckImpact: number 9 | 10 | generates: 11 | ./src/generated/graphqlgen.ts: 12 | schema: ../schema.graphql 13 | plugins: 14 | - typescript 15 | - typescript-resolvers 16 | - typescript-operations 17 | - "codegen/typedefsCodegen.js" 18 | hooks: 19 | afterOneFileWrite: 20 | - prettier --write 21 | -------------------------------------------------------------------------------- /server/codegen/typedefsCodegen.js: -------------------------------------------------------------------------------- 1 | var graphqlUtils = require('@graphql-tools/utils'); 2 | var graphql = require('graphql'); 3 | 4 | // https://github.com/dotansimha/graphql-code-generator/issues/3899 5 | var print = function (schema) { 6 | var escapedSchema = schema.replace(/\\`/g, '\\\\`').replace(/`/g, '\\`'); 7 | 8 | // import { gql } from "@apollo/client/core" 9 | return ( 10 | '\n' + 11 | 'import { gql } from "graphql-tag";\n' + 12 | 'export const typeDefs = gql`' + 13 | escapedSchema + 14 | '`;' 15 | ); 16 | }; 17 | 18 | module.exports = { 19 | plugin: function (schema) { 20 | return print( 21 | graphql.stripIgnoredCharacters(graphqlUtils.printSchemaWithDirectives(schema)), 22 | ); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gitlab-merger-bot/server", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "ts-node --transpileOnly src", 7 | "start": "node lib", 8 | "generate": "graphql-codegen", 9 | "build": "cross-env TS_NODE_PROJECT=\\\"tsconfig.webpack-config.json\\\" NODE_ENV=production esbuild src/index.ts --bundle --platform=node --outfile=lib/index.js", 10 | "build-bin": "pkg -t node20-alpine-$(if [ \"$BUILDPLATFORM\" = \"linux/arm64\" ]; then echo \"arm64\"; else echo \"x64\"; fi) lib/index.js --output ./gitlab-merger-bot", 11 | "format": "prettier --write \"**/*.{js,json,ts,tsx}\"", 12 | "check": "yarn run check:types && yarn run check:tests", 13 | "check:cs": "prettier --check \"**/*.{js,json,ts,tsx}\"", 14 | "check:types": "tsc --noEmit", 15 | "check:tests": "jest" 16 | }, 17 | "dependencies": { 18 | "@apollo/server": "4.10.2", 19 | "@graphql-tools/schema": "10.0.3", 20 | "@graphql-tools/utils": "10.1.2", 21 | "@sentry/node": "7.108.0", 22 | "body-parser": "1.20.3", 23 | "env-var": "7.4.1", 24 | "express": "4.20.0", 25 | "fast-deep-equal": "3.1.3", 26 | "graphql": "15.8.0", 27 | "graphql-subscriptions": "2.0.0", 28 | "graphql-tag": "2.12.6", 29 | "graphql-ws": "5.15.0", 30 | "https-proxy-agent": "7.0.4", 31 | "node-fetch": "2.7.0", 32 | "uuid": "9.0.1", 33 | "ws": "8.17.1" 34 | }, 35 | "devDependencies": { 36 | "@gitlab-merger-bot/common": "*", 37 | "@graphql-codegen/cli": "5.0.2", 38 | "@graphql-codegen/core": "4.0.2", 39 | "@graphql-codegen/typescript": "4.0.6", 40 | "@graphql-codegen/typescript-resolvers": "4.0.6", 41 | "@types/body-parser": "1.19.5", 42 | "@types/express": "4.17.21", 43 | "@types/jest": "29.5.12", 44 | "@types/node": "20.11.30", 45 | "@types/node-fetch": "2.6.11", 46 | "@types/uuid": "9.0.8", 47 | "@yao-pkg/pkg": "^5.11.5", 48 | "cross-env": "7.0.3", 49 | "esbuild": "0.20.2", 50 | "jest": "29.7.0", 51 | "ts-jest": "29.1.2", 52 | "ts-node": "10.9.2", 53 | "typescript": "5.4.3" 54 | }, 55 | "jest": { 56 | "moduleFileExtensions": [ 57 | "ts", 58 | "tsx", 59 | "js" 60 | ], 61 | "transform": { 62 | "^.+\\.(ts|tsx)$": [ 63 | "ts-jest", 64 | { 65 | "tsconfig": "tsconfig.json" 66 | } 67 | ] 68 | }, 69 | "testMatch": [ 70 | "**/src/**/__tests__/*.+(ts|tsx|js)" 71 | ] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /server/src/AssignToAuthor.ts: -------------------------------------------------------------------------------- 1 | import { GitlabApi, MergeRequest, User } from './GitlabApi'; 2 | import { filterBotLabels } from './MergeRequestAcceptor'; 3 | 4 | export const assignToAuthorAndResetLabels = async ( 5 | gitlabApi: GitlabApi, 6 | mergeRequest: MergeRequest, 7 | currentUser: User, 8 | ): Promise => { 9 | await gitlabApi.updateMergeRequest(mergeRequest.target_project_id, mergeRequest.iid, { 10 | assignee_id: currentUser.id !== mergeRequest.author.id ? mergeRequest.author.id : 0, 11 | labels: filterBotLabels(mergeRequest.labels).join(','), 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /server/src/BotLabelsSetter.ts: -------------------------------------------------------------------------------- 1 | import { GitlabApi, MergeRequest } from './GitlabApi'; 2 | import { BotLabels, filterBotLabels } from './MergeRequestAcceptor'; 3 | 4 | export const setBotLabels = async ( 5 | gitlabApi: GitlabApi, 6 | mergeRequest: MergeRequest, 7 | labels: BotLabels[], 8 | ) => { 9 | await gitlabApi.updateMergeRequest(mergeRequest.target_project_id, mergeRequest.iid, { 10 | labels: [...filterBotLabels(mergeRequest.labels), ...labels].join(','), 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /server/src/Config.ts: -------------------------------------------------------------------------------- 1 | import * as env from 'env-var'; 2 | 3 | export const defaultConfig = { 4 | GITLAB_URL: 'https://gitlab.com', 5 | GITLAB_AUTH_TOKEN: '', 6 | CI_CHECK_INTERVAL: 5, 7 | MR_CHECK_INTERVAL: 5, 8 | REMOVE_BRANCH_AFTER_MERGE: true, 9 | SQUASH_MERGE_REQUEST: true, 10 | PREFER_GITLAB_TEMPLATE: false, 11 | AUTORUN_MANUAL_BLOCKING_JOBS: true, 12 | SKIP_SQUASHING_LABEL: 'bot:skip-squash', 13 | HIGH_PRIORITY_LABEL: 'bot:high-priority', 14 | HTTP_SERVER_ENABLE: false, 15 | HTTP_SERVER_PORT: 4000, 16 | WEB_HOOK_TOKEN: '', 17 | WEB_HOOK_HISTORY_SIZE: 100, 18 | DRY_RUN: false, 19 | HTTP_PROXY: '', 20 | ENABLE_PERMISSION_VALIDATION: false, 21 | ALLOWED_PROJECT_IDS: [] as string[], 22 | }; 23 | 24 | export const getConfig = (): Config => ({ 25 | GITLAB_URL: env 26 | .get('GITLAB_URL') 27 | .default(defaultConfig.GITLAB_URL) 28 | .asUrlString() 29 | .replace(/\/$/g, ''), 30 | GITLAB_AUTH_TOKEN: env.get('GITLAB_AUTH_TOKEN').required().asString(), 31 | CI_CHECK_INTERVAL: 32 | env.get('CI_CHECK_INTERVAL').default(`${defaultConfig.CI_CHECK_INTERVAL}`).asIntPositive() * 33 | 1000, 34 | MR_CHECK_INTERVAL: 35 | env.get('MR_CHECK_INTERVAL').default(`${defaultConfig.MR_CHECK_INTERVAL}`).asIntPositive() * 36 | 1000, 37 | REMOVE_BRANCH_AFTER_MERGE: env 38 | .get('REMOVE_BRANCH_AFTER_MERGE') 39 | .default(`${defaultConfig.REMOVE_BRANCH_AFTER_MERGE}`) 40 | .asBoolStrict(), 41 | SQUASH_MERGE_REQUEST: env 42 | .get('SQUASH_MERGE_REQUEST') 43 | .default(`${defaultConfig.SQUASH_MERGE_REQUEST}`) 44 | .asBoolStrict(), 45 | PREFER_GITLAB_TEMPLATE: env 46 | .get('PREFER_GITLAB_TEMPLATE') 47 | .default(`${defaultConfig.PREFER_GITLAB_TEMPLATE}`) 48 | .asBoolStrict(), 49 | AUTORUN_MANUAL_BLOCKING_JOBS: env 50 | .get('AUTORUN_MANUAL_BLOCKING_JOBS') 51 | .default(`${defaultConfig.AUTORUN_MANUAL_BLOCKING_JOBS}`) 52 | .asBoolStrict(), 53 | SKIP_SQUASHING_LABEL: env 54 | .get('SKIP_SQUASHING_LABEL') 55 | .default(defaultConfig.SKIP_SQUASHING_LABEL) 56 | .asString(), 57 | HIGH_PRIORITY_LABEL: env 58 | .get('HIGH_PRIORITY_LABEL') 59 | .default(defaultConfig.HIGH_PRIORITY_LABEL) 60 | .asString(), 61 | HTTP_SERVER_ENABLE: env 62 | .get('HTTP_SERVER_ENABLE') 63 | .default(`${defaultConfig.HTTP_SERVER_ENABLE}`) 64 | .asBoolStrict(), 65 | HTTP_SERVER_PORT: env 66 | .get('HTTP_SERVER_PORT') 67 | .default(`${defaultConfig.HTTP_SERVER_PORT}`) 68 | .asPortNumber(), 69 | WEB_HOOK_TOKEN: env.get('WEB_HOOK_TOKEN').default(defaultConfig.WEB_HOOK_TOKEN).asString(), 70 | WEB_HOOK_HISTORY_SIZE: env 71 | .get('WEB_HOOK_HISTORY_SIZE') 72 | .default(defaultConfig.WEB_HOOK_HISTORY_SIZE) 73 | .asIntPositive(), 74 | DRY_RUN: env.get('DRY_RUN').default(`${defaultConfig.DRY_RUN}`).asBoolStrict(), 75 | HTTP_PROXY: env.get('HTTP_PROXY').default('').asString(), 76 | ENABLE_PERMISSION_VALIDATION: env 77 | .get('ENABLE_PERMISSION_VALIDATION') 78 | .default(`${defaultConfig.ENABLE_PERMISSION_VALIDATION}`) 79 | .asBoolStrict(), 80 | ALLOWED_PROJECT_IDS: env 81 | .get('ALLOWED_PROJECT_IDS') 82 | .default(`${defaultConfig.ALLOWED_PROJECT_IDS}`) 83 | .asArray(), 84 | }); 85 | 86 | export type Config = typeof defaultConfig; 87 | -------------------------------------------------------------------------------- /server/src/GitlabApi.ts: -------------------------------------------------------------------------------- 1 | import fetch, { FetchError, RequestInit, Response } from 'node-fetch'; 2 | import queryString, { ParsedUrlQueryInput } from 'querystring'; 3 | import { sleep } from './Utils'; 4 | import { HttpsProxyAgent } from 'https-proxy-agent'; 5 | import * as console from 'console'; 6 | 7 | export interface User { 8 | id: number; 9 | name: string; 10 | username: string; 11 | email: string; 12 | avatar_url: string; 13 | web_url: string; 14 | } 15 | 16 | export enum DetailedMergeStatus { 17 | BlockedStatus = 'blocked_status', 18 | Checking = 'checking', 19 | Unchecked = 'unchecked', 20 | CiMustPass = 'ci_must_pass', 21 | CiStillRunning = 'ci_still_running', 22 | DiscussionsNotResolved = 'discussions_not_resolved', 23 | DraftStatus = 'draft_status', 24 | ExternalStatusChecks = 'external_status_checks', 25 | Mergeable = 'mergeable', 26 | NotApproved = 'not_approved', 27 | NotOpen = 'not_open', 28 | JiraAssociationMissing = 'jira_association_missing', 29 | NeedsRebase = 'needs_rebase', 30 | NeedRebase = 'need_rebase', // Wrong, but existing value. See https://gitlab.com/gitlab-org/gitlab/-/issues/454409 31 | Conflict = 'conflict', 32 | RequestedChanges = 'requested_changes', 33 | } 34 | 35 | export enum MergeState { 36 | Opened = 'opened', 37 | Closed = 'closed', 38 | Locked = 'locked', 39 | Merged = 'merged', 40 | } 41 | 42 | interface MergeRequestAssignee { 43 | id: number; 44 | } 45 | 46 | export interface ProtectedBranch { 47 | id: number; 48 | name: string; 49 | merge_access_levels: ( 50 | | { 51 | access_level: number; 52 | user_id: null; 53 | group_id: null; 54 | } 55 | | { 56 | access_level: null; 57 | user_id: number; 58 | group_id: null; 59 | } 60 | | { 61 | access_level: null; 62 | user_id: null; 63 | group_id: number; 64 | } 65 | )[]; 66 | } 67 | 68 | interface Author { 69 | id: number; 70 | username: string; 71 | name: string; 72 | web_url: string; 73 | state: 'active' | 'awaiting'; 74 | } 75 | 76 | export interface Member extends Author { 77 | access_level: number; 78 | expires_at: string | null; 79 | } 80 | 81 | export interface ToDo { 82 | id: number; 83 | project: { 84 | id: number; 85 | }; 86 | author: Author; 87 | target: MergeRequest; 88 | } 89 | 90 | export interface MergeRequest { 91 | id: number; 92 | iid: number; 93 | title: string; 94 | author: Author; 95 | assignee: MergeRequestAssignee | null; 96 | assignees: MergeRequestAssignee[]; 97 | detailed_merge_status: DetailedMergeStatus; 98 | web_url: string; 99 | source_branch: string; 100 | target_branch: string; 101 | source_project_id: number; 102 | target_project_id: number; 103 | state: MergeState; 104 | force_remove_source_branch: boolean; 105 | labels: string[]; 106 | squash: boolean; 107 | has_conflicts: boolean; 108 | references: { 109 | full: string; 110 | }; 111 | } 112 | 113 | interface MergeRequestUpdateData extends ParsedUrlQueryInput { 114 | assignee_id?: number; 115 | remove_source_branch?: boolean; 116 | squash?: boolean; 117 | labels?: string; 118 | } 119 | 120 | export enum PipelineStatus { 121 | Running = 'running', 122 | Pending = 'pending', 123 | Success = 'success', 124 | Failed = 'failed', 125 | Canceled = 'canceled', 126 | Skipped = 'skipped', 127 | Created = 'created', 128 | WaitingForResource = 'waiting_for_resource', 129 | Preparing = 'preparing', 130 | Manual = 'manual', 131 | Scheduled = 'scheduled', 132 | } 133 | 134 | export interface MergeRequestPipeline { 135 | id: number; 136 | sha: string; 137 | status: PipelineStatus; 138 | } 139 | 140 | export enum PipelineJobStatus { 141 | Manual = 'manual', 142 | Failed = 'failed', 143 | Canceled = 'canceled', 144 | Pending = 'pending', 145 | Started = 'started', 146 | Running = 'running', 147 | } 148 | 149 | export interface PipelineJob { 150 | id: number; 151 | name: string; 152 | allow_failure: boolean; 153 | status: PipelineJobStatus; 154 | created_at: string; 155 | } 156 | 157 | export interface MergeRequestInfo extends MergeRequest { 158 | sha: string; 159 | diff_refs: { 160 | start_sha: string; 161 | base_sha: string; 162 | head_sha: string; 163 | }; 164 | head_pipeline: MergeRequestPipeline | null; 165 | diverged_commits_count: number; 166 | rebase_in_progress: boolean; 167 | merge_error: string | null; 168 | } 169 | 170 | export interface MergeRequestApprovals { 171 | approvals_required: number; 172 | approvals_left: number; 173 | } 174 | 175 | interface Pipeline { 176 | user: { 177 | id: number; 178 | }; 179 | } 180 | 181 | export enum RequestMethod { 182 | Get = 'get', 183 | Put = 'put', 184 | Post = 'post', 185 | } 186 | 187 | export class GitlabApi { 188 | private readonly gitlabUrl: string; 189 | private readonly authToken: string; 190 | private readonly httpProxy: HttpsProxyAgent | undefined; 191 | 192 | constructor(gitlabUrl: string, authToken: string, httpProxy: string | undefined) { 193 | this.gitlabUrl = gitlabUrl; 194 | this.authToken = authToken; 195 | this.httpProxy = httpProxy ? new HttpsProxyAgent(httpProxy) : undefined; 196 | } 197 | 198 | public async getMe(): Promise { 199 | return this.sendRequestWithSingleResponse(`/api/v4/user`, RequestMethod.Get); 200 | } 201 | 202 | public async getUser(userId: number): Promise { 203 | return this.sendRequestWithSingleResponse(`/api/v4/users/${userId}`, RequestMethod.Get); 204 | } 205 | 206 | public async getProtectedBranch( 207 | projectId: number, 208 | name: string, 209 | ): Promise { 210 | return this.sendRequestWithSingleResponse( 211 | `/api/v4/projects/${projectId}/protected_branches/${encodeURIComponent(name)}`, 212 | RequestMethod.Get, 213 | ); 214 | } 215 | 216 | public async getMember(projectId: number, userId: number): Promise { 217 | return this.sendRequestWithSingleResponse( 218 | `/api/v4/projects/${projectId}/members/all/${userId}`, 219 | RequestMethod.Get, 220 | ); 221 | } 222 | 223 | public async getMergeRequestTodos(): Promise { 224 | return this.sendRequestWithMultiResponse( 225 | `/api/v4/todos?type=MergeRequest&action=assigned&state=pending`, 226 | RequestMethod.Get, 227 | ); 228 | } 229 | 230 | public async markTodoAsDone(todoId: number): Promise { 231 | return this.sendRequestWithSingleResponse( 232 | `/api/v4/todos/${todoId}/mark_as_done`, 233 | RequestMethod.Post, 234 | ); 235 | } 236 | 237 | public async getAssignedOpenedMergeRequests(): Promise { 238 | return this.sendRequestWithMultiResponse( 239 | `/api/v4/merge_requests?scope=assigned_to_me&state=opened`, 240 | RequestMethod.Get, 241 | ); 242 | } 243 | 244 | public async getMergeRequest( 245 | projectId: number, 246 | mergeRequestIid: number, 247 | ): Promise { 248 | return this.sendRequestWithSingleResponse( 249 | `/api/v4/projects/${projectId}/merge_requests/${mergeRequestIid}`, 250 | RequestMethod.Get, 251 | ); 252 | } 253 | 254 | public async getMergeRequestInfo( 255 | projectId: number, 256 | mergeRequestIid: number, 257 | ): Promise { 258 | return this.sendRequestWithSingleResponse( 259 | `/api/v4/projects/${projectId}/merge_requests/${mergeRequestIid}`, 260 | RequestMethod.Get, 261 | { 262 | include_diverged_commits_count: true, 263 | include_rebase_in_progress: true, 264 | }, 265 | ); 266 | } 267 | 268 | public async getPipelineJobs(projectId: number, pipelineId: number): Promise { 269 | return this.sendRequestWithMultiResponse( 270 | `/api/v4/projects/${projectId}/pipelines/${pipelineId}/jobs?per_page=100`, 271 | RequestMethod.Get, 272 | ); 273 | } 274 | 275 | public async getMergeRequestApprovals( 276 | projectId: number, 277 | mergeRequestIid: number, 278 | ): Promise { 279 | return this.sendRequestWithSingleResponse( 280 | `/api/v4/projects/${projectId}/merge_requests/${mergeRequestIid}/approvals`, 281 | RequestMethod.Get, 282 | ); 283 | } 284 | 285 | public async updateMergeRequest( 286 | projectId: number, 287 | mergeRequestIid: number, 288 | data: MergeRequestUpdateData, 289 | ): Promise { 290 | return this.sendRequestWithSingleResponse( 291 | `/api/v4/projects/${projectId}/merge_requests/${mergeRequestIid}`, 292 | RequestMethod.Put, 293 | data, 294 | ); 295 | } 296 | 297 | public async getPipeline(projectId: number, pipelineId: number): Promise { 298 | return this.sendRequestWithSingleResponse( 299 | `/api/v4/projects/${projectId}/pipelines/${pipelineId}`, 300 | RequestMethod.Get, 301 | ); 302 | } 303 | 304 | public async cancelPipeline(projectId: number, pipelineId: number): Promise { 305 | return this.sendRequestWithSingleResponse( 306 | `/api/v4/projects/${projectId}/pipelines/${pipelineId}/cancel`, 307 | RequestMethod.Post, 308 | ); 309 | } 310 | 311 | public async createMergeRequestNote( 312 | projectId: number, 313 | mergeRequestIid: number, 314 | body: string, 315 | ): Promise { 316 | return this.sendRequestWithSingleResponse( 317 | `/api/v4/projects/${projectId}/merge_requests/${mergeRequestIid}/notes`, 318 | RequestMethod.Post, 319 | { 320 | body, 321 | }, 322 | ); 323 | } 324 | 325 | public async rebaseMergeRequest(projectId: number, mergeRequestIid: number): Promise { 326 | const response = await this.sendRawRequest( 327 | `/api/v4/projects/${projectId}/merge_requests/${mergeRequestIid}/rebase`, 328 | RequestMethod.Put, 329 | ); 330 | await this.validateResponseStatus(response); 331 | } 332 | 333 | public async retryJob(projectId: number, jobId: number): Promise { 334 | const response = await this.sendRawRequest( 335 | `/api/v4/projects/${projectId}/jobs/${jobId}/retry`, 336 | RequestMethod.Post, 337 | ); 338 | await this.validateResponseStatus(response); 339 | } 340 | 341 | public async runJob(projectId: number, jobId: number): Promise { 342 | const response = await this.sendRawRequest( 343 | `/api/v4/projects/${projectId}/jobs/${jobId}/play`, 344 | RequestMethod.Post, 345 | ); 346 | await this.validateResponseStatus(response); 347 | } 348 | 349 | private async sendRequestWithSingleResponse( 350 | url: string, 351 | method: RequestMethod, 352 | body?: ParsedUrlQueryInput, 353 | ): Promise { 354 | const response = await this.sendRawRequest(url, method, body); 355 | if (response.status === 404) { 356 | return null; 357 | } 358 | 359 | await this.validateResponseStatus(response); 360 | 361 | const data = await response.json(); 362 | if (typeof data !== 'object' || data === null || !('id' in data) || data.id === undefined) { 363 | console.error('response', data); 364 | throw new Error('Invalid response'); 365 | } 366 | 367 | return data; 368 | } 369 | 370 | private async sendRequestWithMultiResponse( 371 | url: string, 372 | method: RequestMethod, 373 | body?: ParsedUrlQueryInput, 374 | ): Promise { 375 | const response = await this.sendRawRequest(url, method, body); 376 | await this.validateResponseStatus(response); 377 | 378 | const data = await response.json(); 379 | if (!Array.isArray(data)) { 380 | console.error('response', data); 381 | throw new Error('Invalid response'); 382 | } 383 | 384 | return data; 385 | } 386 | 387 | private async validateResponseStatus(response: Response): Promise { 388 | if (response.status === 401) { 389 | throw new Error('Unauthorized'); 390 | } 391 | 392 | if (response.status === 403) { 393 | throw new Error('Forbidden'); 394 | } 395 | 396 | if (response.status < 200 || response.status >= 300) { 397 | throw new Error(`Unexpected status code: ${response.status} ${await response.json()}`); 398 | } 399 | } 400 | 401 | public async sendRawRequest( 402 | url: string, 403 | method: RequestMethod, 404 | body?: ParsedUrlQueryInput, 405 | ): Promise { 406 | const options: RequestInit = { 407 | method, 408 | timeout: 10000, 409 | headers: { 410 | 'Private-Token': this.authToken, 411 | 'Content-Type': 'application/json', 412 | }, 413 | agent: this.httpProxy, 414 | }; 415 | 416 | if (body !== undefined) { 417 | if (method === RequestMethod.Get) { 418 | url = url + '?' + queryString.stringify(body); 419 | } else { 420 | options.body = JSON.stringify(body); 421 | } 422 | } 423 | 424 | const requestUrl = `${this.gitlabUrl}${url}`; 425 | 426 | const numberOfRequestRetries = 20; 427 | let retryCounter = 0; 428 | while (true) { 429 | retryCounter++; 430 | 431 | try { 432 | const response = await fetch(requestUrl, options); 433 | if (response.status >= 500) { 434 | if (retryCounter >= numberOfRequestRetries) { 435 | throw new Error( 436 | `Unexpected status code ${response.status} after ${numberOfRequestRetries} retries`, 437 | ); 438 | } 439 | 440 | const sleepTimeout = 10000; 441 | console.log( 442 | `GitLab request ${method.toUpperCase()} ${requestUrl} responded with a status ${ 443 | response.status 444 | }, I'll try it again after ${sleepTimeout}ms`, 445 | ); 446 | await sleep(sleepTimeout); 447 | continue; 448 | } 449 | 450 | return response; 451 | } catch (e) { 452 | if ( 453 | retryCounter < numberOfRequestRetries && 454 | e instanceof FetchError && 455 | ['system', 'request-timeout'].includes(e.type) // `getaddrinfo EAI_AGAIN` errors etc. see https://github.com/bitinn/node-fetch/blob/master/src/index.js#L108 456 | ) { 457 | const sleepTimeout = 10000; 458 | console.log( 459 | `GitLab request ${method.toUpperCase()} ${requestUrl} failed: ${ 460 | e.message 461 | }, I'll try it again after ${sleepTimeout}ms`, 462 | ); 463 | await sleep(sleepTimeout); 464 | continue; 465 | } 466 | 467 | throw e; 468 | } 469 | } 470 | } 471 | } 472 | -------------------------------------------------------------------------------- /server/src/Job.ts: -------------------------------------------------------------------------------- 1 | import deepEqual from 'fast-deep-equal'; 2 | import { JobInfo, JobPriority, JobStatus, Job as GQLJob } from './generated/graphqlgen'; 3 | 4 | export interface JobArgs { 5 | success: () => void; 6 | job: Job; 7 | } 8 | 9 | export type JobFunction = (args: JobArgs) => Promise | unknown; 10 | 11 | export class Job { 12 | private _info: JobInfo; 13 | private _status: JobStatus; 14 | private _priority: JobPriority; 15 | 16 | private readonly _id: string; 17 | private readonly _fn: JobFunction; 18 | private readonly onChange: () => unknown; 19 | 20 | constructor( 21 | id: string, 22 | fn: JobFunction, 23 | info: JobInfo, 24 | priority: JobPriority, 25 | onChange: () => unknown, 26 | ) { 27 | this._id = id; 28 | this._fn = fn; 29 | this._info = info; 30 | this._priority = priority; 31 | this.onChange = onChange; 32 | 33 | this._status = JobStatus.WAITING; 34 | } 35 | 36 | public updateStatus(status: JobStatus): void { 37 | if (this._status === status) { 38 | return; 39 | } 40 | 41 | this._status = status; 42 | this.onChange(); 43 | } 44 | 45 | public updateInfo(info: JobInfo): void { 46 | if (deepEqual(this._info, info)) { 47 | return; 48 | } 49 | 50 | this._info = info; 51 | this.onChange(); 52 | } 53 | 54 | public updatePriority(priority: JobPriority): void { 55 | if (deepEqual(this._priority, priority)) { 56 | return; 57 | } 58 | 59 | this._priority = priority; 60 | this.onChange(); 61 | } 62 | 63 | public run(args: JobArgs): Promise | unknown { 64 | return this._fn(args); 65 | } 66 | 67 | get id(): string { 68 | return this._id; 69 | } 70 | 71 | get status(): JobStatus { 72 | return this._status; 73 | } 74 | 75 | get info(): JobInfo { 76 | return this._info; 77 | } 78 | 79 | get priority(): JobPriority { 80 | return this._priority; 81 | } 82 | 83 | public getData(): GQLJob { 84 | return { 85 | priority: this.priority, 86 | info: this.info, 87 | status: this.status, 88 | }; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /server/src/MergeRequestAcceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DetailedMergeStatus, 3 | GitlabApi, 4 | MergeRequest, 5 | MergeRequestInfo, 6 | MergeState, 7 | PipelineJob, 8 | PipelineJobStatus, 9 | PipelineStatus, 10 | RequestMethod, 11 | User, 12 | } from './GitlabApi'; 13 | import { tryCancelPipeline } from './PipelineCanceller'; 14 | import { setBotLabels } from './BotLabelsSetter'; 15 | import { Config } from './Config'; 16 | import { Job } from './Job'; 17 | import { JobStatus } from './generated/graphqlgen'; 18 | import { assignToAuthorAndResetLabels } from './AssignToAuthor'; 19 | import { sendNote } from './SendNote'; 20 | 21 | export enum BotLabels { 22 | InMergeQueue = 'in-merge-queue', 23 | Accepting = 'accepting', 24 | WaitingForPipeline = 'waiting-for-pipeline', 25 | } 26 | 27 | const containsLabel = (labels: string[], label: BotLabels) => labels.includes(label); 28 | export const containsAssignedUser = (mergeRequest: MergeRequest, user: User) => { 29 | const userIds = mergeRequest.assignees.map((assignee) => assignee.id); 30 | return userIds.includes(user.id); 31 | }; 32 | 33 | export const filterBotLabels = (labels: string[]): string[] => { 34 | const values = Object.values(BotLabels) as string[]; 35 | 36 | return labels.filter((label) => !values.includes(label)); 37 | }; 38 | 39 | const uniqueNamedJobsByDate = (jobs: PipelineJob[]): PipelineJob[] => { 40 | const jobRecord: Record = {}; 41 | jobs.forEach((job) => { 42 | if (jobRecord[job.name] !== undefined) { 43 | const currentJob = jobRecord[job.name]; 44 | const currentJobCreatedAt = new Date(currentJob.created_at); 45 | 46 | if (currentJobCreatedAt > new Date(job.created_at)) { 47 | return; 48 | } 49 | } 50 | 51 | jobRecord[job.name] = job; 52 | }); 53 | 54 | return Object.values(jobRecord); 55 | }; 56 | 57 | export const acceptMergeRequest = async ( 58 | job: Job, 59 | gitlabApi: GitlabApi, 60 | projectId: number, 61 | mergeRequestIid: number, 62 | user: User, 63 | config: Config, 64 | ): Promise<'continue' | 'done'> => { 65 | console.log(`[MR][${mergeRequestIid}] Checking...`); 66 | 67 | const mergeRequestInfo = await gitlabApi.getMergeRequestInfo(projectId, mergeRequestIid); 68 | if (mergeRequestInfo.state === MergeState.Merged) { 69 | console.log(`[MR][${mergeRequestInfo.iid}] Merge request is merged, ending`); 70 | await setBotLabels(gitlabApi, mergeRequestInfo, []); 71 | 72 | return 'done'; 73 | } 74 | 75 | if (!containsAssignedUser(mergeRequestInfo, user)) { 76 | console.log( 77 | `[MR][${mergeRequestInfo.iid}] Merge request is assigned to different user, ending`, 78 | ); 79 | await setBotLabels(gitlabApi, mergeRequestInfo, []); 80 | 81 | return 'done'; 82 | } 83 | 84 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.NotOpen) { 85 | const message = 'The merge request is not open anymore.'; 86 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 87 | 88 | await Promise.all([ 89 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 90 | sendNote(gitlabApi, mergeRequestInfo, message), 91 | ]); 92 | 93 | return 'done'; 94 | } 95 | 96 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.DiscussionsNotResolved) { 97 | const message = "The merge request has unresolved discussion, I can't merge it."; 98 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 99 | await Promise.all([ 100 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 101 | sendNote(gitlabApi, mergeRequestInfo, message), 102 | ]); 103 | 104 | return 'done'; 105 | } 106 | 107 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.DraftStatus) { 108 | const message = 'The merge request is marked as a draft'; 109 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 110 | 111 | await Promise.all([ 112 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 113 | sendNote(gitlabApi, mergeRequestInfo, message), 114 | ]); 115 | 116 | return 'done'; 117 | } 118 | 119 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.RequestedChanges) { 120 | const message = 'The merge request has Reviewers who have requested changes'; 121 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 122 | 123 | await Promise.all([ 124 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 125 | sendNote(gitlabApi, mergeRequestInfo, message), 126 | ]); 127 | 128 | return 'done'; 129 | } 130 | 131 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.JiraAssociationMissing) { 132 | const message = 'The merge request title or description must reference a Jira issue.'; 133 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 134 | 135 | await Promise.all([ 136 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 137 | sendNote(gitlabApi, mergeRequestInfo, message), 138 | ]); 139 | 140 | return 'done'; 141 | } 142 | 143 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.ExternalStatusChecks) { 144 | const message = 'All external status checks must pass before merge.'; 145 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 146 | 147 | await Promise.all([ 148 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 149 | sendNote(gitlabApi, mergeRequestInfo, message), 150 | ]); 151 | 152 | return 'done'; 153 | } 154 | 155 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.BlockedStatus) { 156 | const message = 'The merge request is blocked by another merge request'; 157 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 158 | 159 | await Promise.all([ 160 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 161 | sendNote(gitlabApi, mergeRequestInfo, message), 162 | ]); 163 | 164 | return 'done'; 165 | } 166 | 167 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.Conflict) { 168 | const message = 'The merge request has conflict'; 169 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 170 | await Promise.all([ 171 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 172 | sendNote(gitlabApi, mergeRequestInfo, message), 173 | ]); 174 | 175 | return 'done'; 176 | } 177 | 178 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.NotApproved) { 179 | const approvals = await gitlabApi.getMergeRequestApprovals( 180 | mergeRequestInfo.target_project_id, 181 | mergeRequestInfo.iid, 182 | ); 183 | const message = `The merge request is waiting for approvals. Required ${approvals.approvals_required}, but ${approvals.approvals_left} left.`; 184 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 185 | await Promise.all([ 186 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 187 | sendNote(gitlabApi, mergeRequestInfo, message), 188 | ]); 189 | 190 | return 'done'; 191 | } 192 | 193 | if ( 194 | mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.Checking || 195 | mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.Unchecked 196 | ) { 197 | console.log(`[MR][${mergeRequestInfo.iid}] Still checking merge status`); 198 | job.updateStatus(JobStatus.CHECKING_MERGE_STATUS); 199 | return 'continue'; 200 | } 201 | 202 | if (mergeRequestInfo.rebase_in_progress) { 203 | console.log(`[MR][${mergeRequestInfo.iid}] Still rebasing`); 204 | job.updateStatus(JobStatus.REBASING); 205 | return 'continue'; 206 | } 207 | 208 | if ( 209 | mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.NeedsRebase || 210 | mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.NeedRebase 211 | ) { 212 | console.log(`[MR][${mergeRequestInfo.iid}] source branch is not up to date, rebasing`); 213 | await tryCancelPipeline(gitlabApi, mergeRequestInfo, user); 214 | await gitlabApi.rebaseMergeRequest( 215 | mergeRequestInfo.target_project_id, 216 | mergeRequestInfo.iid, 217 | ); 218 | job.updateStatus(JobStatus.REBASING); 219 | return 'continue'; 220 | } 221 | 222 | const currentPipeline = mergeRequestInfo.head_pipeline; 223 | if (currentPipeline === null) { 224 | const message = `The merge request can't be merged. Pipeline does not exist`; 225 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 226 | await Promise.all([ 227 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 228 | sendNote(gitlabApi, mergeRequestInfo, message), 229 | ]); 230 | 231 | return 'done'; 232 | } 233 | 234 | if (currentPipeline.status === PipelineStatus.Failed) { 235 | const message = `The merge request can't be merged due to failing pipeline`; 236 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 237 | await Promise.all([ 238 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 239 | sendNote(gitlabApi, mergeRequestInfo, message), 240 | ]); 241 | 242 | return 'done'; 243 | } 244 | 245 | if (currentPipeline.status === PipelineStatus.Skipped) { 246 | const message = `The merge request can't be merged due to skipped pipeline`; 247 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 248 | await Promise.all([ 249 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 250 | sendNote(gitlabApi, mergeRequestInfo, message), 251 | ]); 252 | 253 | return 'done'; 254 | } 255 | 256 | if ( 257 | [DetailedMergeStatus.CiMustPass, DetailedMergeStatus.CiStillRunning].includes( 258 | mergeRequestInfo.detailed_merge_status, 259 | ) && 260 | [PipelineStatus.Manual, PipelineStatus.Canceled].includes(currentPipeline.status) 261 | ) { 262 | const jobs = uniqueNamedJobsByDate( 263 | await gitlabApi.getPipelineJobs(mergeRequestInfo.target_project_id, currentPipeline.id), 264 | ); 265 | 266 | // Mark pipeline as failed when a failed job is found 267 | const failedJob = jobs.find( 268 | (job) => !job.allow_failure && job.status === PipelineJobStatus.Failed, 269 | ); 270 | if (failedJob !== undefined) { 271 | console.log( 272 | `[MR][${mergeRequestInfo.iid}] job in pipeline is in failed state: ${currentPipeline.status}, assigning back`, 273 | ); 274 | 275 | await Promise.all([ 276 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 277 | sendNote( 278 | gitlabApi, 279 | mergeRequestInfo, 280 | `The merge request can't be merged due to failing pipeline`, 281 | ), 282 | ]); 283 | 284 | return 'done'; 285 | } 286 | 287 | const manualJobsToRun = jobs.filter( 288 | (job) => PipelineJobStatus.Manual === job.status && !job.allow_failure, 289 | ); 290 | const canceledJobsToRun = jobs.filter( 291 | (job) => PipelineJobStatus.Canceled === job.status && !job.allow_failure, 292 | ); 293 | 294 | if (manualJobsToRun.length > 0 || canceledJobsToRun.length > 0) { 295 | if (!config.AUTORUN_MANUAL_BLOCKING_JOBS) { 296 | console.log( 297 | `[MR][${mergeRequestInfo.iid}] pipeline is waiting for a manual action: ${currentPipeline.status}, assigning back`, 298 | ); 299 | 300 | await Promise.all([ 301 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 302 | sendNote( 303 | gitlabApi, 304 | mergeRequestInfo, 305 | `The merge request can't be merged. Pipeline is waiting for a manual user action.`, 306 | ), 307 | ]); 308 | 309 | return 'done'; 310 | } 311 | 312 | console.log( 313 | `[MR][${mergeRequestInfo.iid}] there are some blocking manual or canceled. triggering again`, 314 | ); 315 | await Promise.all( 316 | manualJobsToRun.map((job) => 317 | gitlabApi.runJob(mergeRequestInfo.target_project_id, job.id), 318 | ), 319 | ); 320 | await Promise.all( 321 | canceledJobsToRun.map((job) => 322 | gitlabApi.retryJob(mergeRequestInfo.target_project_id, job.id), 323 | ), 324 | ); 325 | job.updateStatus(JobStatus.WAITING_FOR_CI); 326 | return 'continue'; 327 | } 328 | } 329 | 330 | if ( 331 | mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.CiStillRunning || 332 | // I don't understand why the merge status is `ci_must_pass` instead of `ci_still_running`, but it is as it is. Maybe more values for currentPipeline.status should be added here, but let's try it in this way for now. 333 | (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.CiMustPass && 334 | [ 335 | PipelineStatus.Running, 336 | PipelineStatus.Created, 337 | PipelineStatus.Success, 338 | PipelineStatus.Pending, 339 | PipelineStatus.Preparing, 340 | ].includes(currentPipeline.status)) 341 | ) { 342 | await setBotLabels(gitlabApi, mergeRequestInfo, [BotLabels.WaitingForPipeline]); 343 | job.updateStatus(JobStatus.WAITING_FOR_CI); 344 | return 'continue'; 345 | } 346 | 347 | if (mergeRequestInfo.detailed_merge_status !== DetailedMergeStatus.Mergeable) { 348 | const message = `The merge request can't be merged due to unexpected status. Merge status: ${mergeRequestInfo.detailed_merge_status} and pipeline status: ${currentPipeline.status}`; 349 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 350 | 351 | await Promise.all([ 352 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 353 | sendNote(gitlabApi, mergeRequestInfo, message), 354 | ]); 355 | 356 | return 'done'; 357 | } 358 | 359 | if (mergeRequestInfo.merge_error !== null) { 360 | const message = `The merge request can't be merged: ${mergeRequestInfo.merge_error}`; 361 | console.log(`[MR][${mergeRequestInfo.iid}] merge failed: ${message}, assigning back`); 362 | 363 | await Promise.all([ 364 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 365 | sendNote(gitlabApi, mergeRequestInfo, message), 366 | ]); 367 | 368 | return 'done'; 369 | } 370 | 371 | if (!containsLabel(mergeRequestInfo.labels, BotLabels.Accepting)) { 372 | await setBotLabels(gitlabApi, mergeRequestInfo, [BotLabels.Accepting]); 373 | } 374 | 375 | if (config.DRY_RUN) { 376 | console.log(`[MR][${mergeRequestInfo.iid}] Still checking merge status`); 377 | job.updateStatus(JobStatus.CHECKING_MERGE_STATUS); 378 | return 'continue'; 379 | } 380 | 381 | return mergeMergeRequest({ 382 | mergeRequestInfo, 383 | job, 384 | gitlabApi, 385 | config, 386 | user, 387 | }); 388 | }; 389 | 390 | export const mergeMergeRequest = async ({ 391 | mergeRequestInfo, 392 | job, 393 | gitlabApi, 394 | config, 395 | user, 396 | }: { 397 | mergeRequestInfo: MergeRequestInfo; 398 | job?: Job; 399 | gitlabApi: GitlabApi; 400 | config: Config; 401 | user: User; 402 | }) => { 403 | // Let's merge it 404 | const useSquash = mergeRequestInfo.labels.includes(config.SKIP_SQUASHING_LABEL) 405 | ? false 406 | : config.SQUASH_MERGE_REQUEST; 407 | if (mergeRequestInfo.squash && !useSquash) { 408 | // Because usage `squash=false` during accept MR has no effect and it just uses squash setting from the MR 409 | await gitlabApi.updateMergeRequest( 410 | mergeRequestInfo.target_project_id, 411 | mergeRequestInfo.iid, 412 | { 413 | squash: false, 414 | }, 415 | ); 416 | } 417 | 418 | type BodyStructure = { 419 | should_remove_source_branch: boolean; 420 | sha: string; 421 | squash: boolean; 422 | squash_commit_message?: string; 423 | merge_commit_message?: string; 424 | }; 425 | 426 | const requestBody: BodyStructure = { 427 | should_remove_source_branch: config.REMOVE_BRANCH_AFTER_MERGE, 428 | sha: mergeRequestInfo.diff_refs.head_sha, 429 | squash: useSquash, 430 | }; 431 | 432 | if (!config.PREFER_GITLAB_TEMPLATE) { 433 | requestBody.squash_commit_message = `${mergeRequestInfo.title} (!${mergeRequestInfo.iid})`; 434 | requestBody.merge_commit_message = `${mergeRequestInfo.title} (!${mergeRequestInfo.iid})`; 435 | } 436 | 437 | const response = await gitlabApi.sendRawRequest( 438 | `/api/v4/projects/${mergeRequestInfo.target_project_id}/merge_requests/${mergeRequestInfo.iid}/merge`, 439 | RequestMethod.Put, 440 | requestBody, 441 | ); 442 | 443 | if (response.status === 405 || response.status === 406) { 444 | // GitLab 405 is a mixed state and can be a temporary error 445 | // as long as all flags and status indicate that we can merge, retry 446 | if (mergeRequestInfo.detailed_merge_status === DetailedMergeStatus.Mergeable) { 447 | console.log( 448 | `[MR][${mergeRequestInfo.iid}] ${response.status} - cannot be merged but merge status is: ${mergeRequestInfo.detailed_merge_status}`, 449 | ); 450 | 451 | if (job) { 452 | job.updateStatus(JobStatus.CHECKING_MERGE_STATUS); 453 | } 454 | } 455 | 456 | return 'continue'; 457 | } 458 | 459 | if (response.status === 409) { 460 | console.log( 461 | `[MR][${mergeRequestInfo.iid}] ${response.status} - SHA does not match HEAD of source branch`, 462 | ); 463 | return 'continue'; 464 | } 465 | 466 | if (response.status === 401) { 467 | console.log( 468 | `[MR][${mergeRequestInfo.iid}] You don't have permissions to accept this merge request, assigning back`, 469 | ); 470 | 471 | await Promise.all([ 472 | assignToAuthorAndResetLabels(gitlabApi, mergeRequestInfo, user), 473 | sendNote( 474 | gitlabApi, 475 | mergeRequestInfo, 476 | `The merge request can't be merged due to insufficient authorization`, 477 | ), 478 | ]); 479 | 480 | return 'done'; 481 | } 482 | 483 | if (response.status !== 200) { 484 | throw new Error(`Unsupported response status ${response.status}`); 485 | } 486 | 487 | const data = await response.json(); 488 | if (typeof data !== 'object' && data.id === undefined) { 489 | console.error('response', data); 490 | throw new Error('Invalid response'); 491 | } 492 | 493 | return 'done'; 494 | }; 495 | -------------------------------------------------------------------------------- /server/src/MergeRequestCheckerLoop.ts: -------------------------------------------------------------------------------- 1 | import { GitlabApi, MergeRequest, ToDo, User } from './GitlabApi'; 2 | import { prepareMergeRequestForMerge } from './MergeRequestReceiver'; 3 | import { Config } from './Config'; 4 | import { Worker } from './Worker'; 5 | 6 | export class MergeRequestCheckerLoop { 7 | private _stop: boolean = true; 8 | private timer: NodeJS.Timeout | null = null; 9 | private onStop: (() => unknown) | null = null; 10 | 11 | private readonly gitlabApi: GitlabApi; 12 | private readonly config: Config; 13 | private readonly user: User; 14 | private readonly worker: Worker; 15 | 16 | constructor(gitlabApi: GitlabApi, config: Config, user: User, worker: Worker) { 17 | this.gitlabApi = gitlabApi; 18 | this.config = config; 19 | this.user = user; 20 | this.worker = worker; 21 | } 22 | 23 | public start(): void { 24 | if (!this._stop) { 25 | return; 26 | } 27 | 28 | console.log('[loop] Starting'); 29 | this._stop = false; 30 | this.loop().catch((error) => console.error(`Error: ${JSON.stringify(error)}`)); 31 | } 32 | 33 | private async loop(): Promise { 34 | await this.task() 35 | .catch((error) => console.error(`Error: ${JSON.stringify(error)}`)) 36 | .then(() => { 37 | if (this._stop) { 38 | console.log('[loop] Stopped'); 39 | if (this.onStop) { 40 | this.onStop(); 41 | this.onStop = null; 42 | } 43 | return; 44 | } 45 | 46 | this.timer = setTimeout(() => { 47 | this.timer = null; 48 | this.loop().catch((error) => console.error(`Error: ${JSON.stringify(error)}`)); 49 | }, this.config.MR_CHECK_INTERVAL); 50 | }); 51 | } 52 | 53 | public async stop(): Promise { 54 | if (this._stop || this.onStop !== null) { 55 | return; 56 | } 57 | 58 | console.log('[loop] Shutting down'); 59 | if (this.timer !== null) { 60 | clearTimeout(this.timer); 61 | console.log('[loop] Stopped'); 62 | return; 63 | } 64 | 65 | return new Promise((resolve) => { 66 | this.onStop = resolve; 67 | this._stop = true; 68 | }); 69 | } 70 | 71 | private async task() { 72 | if (this.config.ENABLE_PERMISSION_VALIDATION) { 73 | console.log('[loop] Checking new todos'); 74 | const mergeRequestTodos = await this.gitlabApi.getMergeRequestTodos(); 75 | const possibleToAcceptMergeRequests = mergeRequestTodos.map((mergeRequestTodo: ToDo) => 76 | prepareMergeRequestForMerge(this.gitlabApi, this.user, this.worker, this.config, { 77 | mergeRequestTodo, 78 | }), 79 | ); 80 | 81 | await Promise.all(possibleToAcceptMergeRequests); 82 | return; 83 | } 84 | 85 | console.log('[loop] Checking assigned merge requests'); 86 | const assignedMergeRequests = await this.gitlabApi.getAssignedOpenedMergeRequests(); 87 | const possibleToAcceptMergeRequests = assignedMergeRequests.map( 88 | (mergeRequest: MergeRequest) => 89 | prepareMergeRequestForMerge(this.gitlabApi, this.user, this.worker, this.config, { 90 | mergeRequest, 91 | }), 92 | ); 93 | 94 | await Promise.all(possibleToAcceptMergeRequests); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /server/src/MergeRequestReceiver.ts: -------------------------------------------------------------------------------- 1 | import { DetailedMergeStatus, GitlabApi, MergeRequest, MergeState, ToDo, User } from './GitlabApi'; 2 | import { assignToAuthorAndResetLabels } from './AssignToAuthor'; 3 | import { sendNote } from './SendNote'; 4 | import { 5 | BotLabels, 6 | containsAssignedUser, 7 | mergeMergeRequest, 8 | acceptMergeRequest, 9 | } from './MergeRequestAcceptor'; 10 | import { setBotLabels } from './BotLabelsSetter'; 11 | import { Worker } from './Worker'; 12 | import { Config } from './Config'; 13 | import { JobInfo, JobPriority } from './generated/graphqlgen'; 14 | import { formatQueueId } from './Utils'; 15 | 16 | export const prepareMergeRequestForMerge = async ( 17 | gitlabApi: GitlabApi, 18 | user: User, 19 | worker: Worker, 20 | config: Config, 21 | mergeRequestData: 22 | | { 23 | mergeRequestTodo: ToDo; 24 | } 25 | | { 26 | mergeRequest: MergeRequest; 27 | }, 28 | ) => { 29 | const { mergeRequest, author } = (() => { 30 | if ('mergeRequestTodo' in mergeRequestData) { 31 | return { 32 | mergeRequest: mergeRequestData.mergeRequestTodo.target, 33 | author: mergeRequestData.mergeRequestTodo.author, 34 | }; 35 | } 36 | 37 | return { 38 | mergeRequest: mergeRequestData.mergeRequest, 39 | author: null, 40 | }; 41 | })(); 42 | 43 | const jobId = `accept-merge-${mergeRequest.id}`; 44 | const jobPriority = mergeRequest.labels.includes(config.HIGH_PRIORITY_LABEL) 45 | ? JobPriority.HIGH 46 | : JobPriority.NORMAL; 47 | const jobInfo: JobInfo = { 48 | mergeRequest: { 49 | iid: mergeRequest.iid, 50 | projectId: mergeRequest.target_project_id, 51 | authorId: mergeRequest.author.id, 52 | title: mergeRequest.title, 53 | webUrl: mergeRequest.web_url, 54 | }, 55 | }; 56 | 57 | const currentJob = worker.findJob(formatQueueId(mergeRequest), jobId); 58 | if (currentJob !== null) { 59 | currentJob.updateInfo(jobInfo); 60 | } 61 | 62 | const currentJobPriority = worker.findJobPriorityInQueue(formatQueueId(mergeRequest), jobId); 63 | if (currentJobPriority === jobPriority) { 64 | return; 65 | } 66 | 67 | if (currentJobPriority !== null) { 68 | console.log(`[loop][MR][${mergeRequest.iid}] Changing job priority to ${jobPriority}.`); 69 | worker.setJobPriority(formatQueueId(mergeRequest), jobId, jobPriority); 70 | return; 71 | } 72 | 73 | if (!containsAssignedUser(mergeRequest, user)) { 74 | if ('mergeRequestTodo' in mergeRequestData) { 75 | await gitlabApi.markTodoAsDone(mergeRequestData.mergeRequestTodo.id); 76 | } 77 | 78 | return; 79 | } 80 | 81 | if (mergeRequest.state === MergeState.Merged) { 82 | await setBotLabels(gitlabApi, mergeRequest, []); 83 | return; 84 | } 85 | 86 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.NotOpen) { 87 | await Promise.all([ 88 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 89 | setBotLabels(gitlabApi, mergeRequest, []), 90 | ]); 91 | 92 | return; 93 | } 94 | 95 | if ( 96 | config.ALLOWED_PROJECT_IDS.length > 0 && 97 | !config.ALLOWED_PROJECT_IDS.includes(mergeRequest.target_project_id.toString()) 98 | ) { 99 | await Promise.all([ 100 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 101 | sendNote( 102 | gitlabApi, 103 | mergeRequest, 104 | `I can't merge it because I'm not allowed to operate on this project.`, 105 | ), 106 | ]); 107 | } 108 | 109 | // Validate permissions 110 | if (author !== null) { 111 | const protectedBranch = await gitlabApi.getProtectedBranch( 112 | mergeRequest.target_project_id, 113 | mergeRequest.target_branch, 114 | ); 115 | if (protectedBranch !== null) { 116 | const member = await gitlabApi.getMember(mergeRequest.target_project_id, author.id); 117 | if (member === null) { 118 | await Promise.all([ 119 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 120 | sendNote( 121 | gitlabApi, 122 | mergeRequest, 123 | `I can't merge it because the merge request was made by ${author.username} who is unauthorized for this instruction.`, 124 | ), 125 | ]); 126 | 127 | return; 128 | } 129 | 130 | const hasAccessLevel = protectedBranch.merge_access_levels.find((mergeAccessLevel) => { 131 | if (mergeAccessLevel.user_id !== null && member.id === mergeAccessLevel.user_id) { 132 | return true; 133 | } 134 | 135 | if ( 136 | mergeAccessLevel.access_level !== null && 137 | member.access_level >= mergeAccessLevel.access_level 138 | ) { 139 | return true; 140 | } 141 | 142 | return false; 143 | }); 144 | if (!hasAccessLevel) { 145 | await Promise.all([ 146 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 147 | sendNote( 148 | gitlabApi, 149 | mergeRequest, 150 | `I can't merge it because the merge request was made by ${author.username} who doesn't pass the protection of the target branch.`, 151 | ), 152 | ]); 153 | 154 | return; 155 | } 156 | } 157 | } 158 | 159 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.DiscussionsNotResolved) { 160 | const message = "The merge request has unresolved discussion, I can't merge it."; 161 | console.log(`[loop][MR][${mergeRequest.iid}] merge failed: ${message}, assigning back`); 162 | await Promise.all([ 163 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 164 | sendNote(gitlabApi, mergeRequest, message), 165 | ]); 166 | 167 | return; 168 | } 169 | 170 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.DraftStatus) { 171 | const message = 'The merge request is marked as a draft'; 172 | console.log(`[loop][MR][${mergeRequest.iid}] merge failed: ${message}, assigning back`); 173 | await Promise.all([ 174 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 175 | sendNote(gitlabApi, mergeRequest, message), 176 | ]); 177 | 178 | return; 179 | } 180 | 181 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.RequestedChanges) { 182 | const message = 'The merge request has Reviewers who have requested changes'; 183 | console.log(`[loop][MR][${mergeRequest.iid}] merge failed: ${message}, assigning back`); 184 | await Promise.all([ 185 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 186 | sendNote(gitlabApi, mergeRequest, message), 187 | ]); 188 | 189 | return; 190 | } 191 | 192 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.JiraAssociationMissing) { 193 | const message = 'The merge request title or description must reference a Jira issue.'; 194 | console.log(`[loop][MR][${mergeRequest.iid}] merge failed: ${message}, assigning back`); 195 | await Promise.all([ 196 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 197 | sendNote(gitlabApi, mergeRequest, message), 198 | ]); 199 | 200 | return; 201 | } 202 | 203 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.ExternalStatusChecks) { 204 | const message = 'All external status checks must pass before merge.'; 205 | console.log(`[loop][MR][${mergeRequest.iid}] merge failed: ${message}, assigning back`); 206 | await Promise.all([ 207 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 208 | sendNote(gitlabApi, mergeRequest, message), 209 | ]); 210 | 211 | return; 212 | } 213 | 214 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.BlockedStatus) { 215 | const message = 'The merge request is blocked by another merge request'; 216 | console.log(`[loop][MR][${mergeRequest.iid}] merge failed: ${message}, assigning back`); 217 | await Promise.all([ 218 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 219 | sendNote(gitlabApi, mergeRequest, message), 220 | ]); 221 | 222 | return; 223 | } 224 | 225 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.Conflict) { 226 | const message = 'The merge request has conflict'; 227 | await Promise.all([ 228 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 229 | sendNote(gitlabApi, mergeRequest, "Merge request can't be merged: MR has conflict"), 230 | ]); 231 | 232 | return; 233 | } 234 | 235 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.NotApproved) { 236 | const approvals = await gitlabApi.getMergeRequestApprovals( 237 | mergeRequest.target_project_id, 238 | mergeRequest.iid, 239 | ); 240 | const message = `The merge request is waiting for approvals. Required ${approvals.approvals_required}, but ${approvals.approvals_left} left.`; 241 | console.log(`[loop][MR][${mergeRequest.iid}] merge failed: ${message}, assigning back`); 242 | await Promise.all([ 243 | assignToAuthorAndResetLabels(gitlabApi, mergeRequest, user), 244 | sendNote(gitlabApi, mergeRequest, message), 245 | ]); 246 | 247 | return; 248 | } 249 | 250 | if (jobPriority === JobPriority.HIGH) { 251 | if (mergeRequest.detailed_merge_status === DetailedMergeStatus.Mergeable) { 252 | const mergeRequestInfo = await gitlabApi.getMergeRequestInfo( 253 | mergeRequest.target_project_id, 254 | mergeRequest.iid, 255 | ); 256 | const mergeResponse = await mergeMergeRequest({ 257 | gitlabApi, 258 | mergeRequestInfo, 259 | config, 260 | user, 261 | }); 262 | if (mergeResponse === 'done') { 263 | console.log( 264 | `[loop][MR][${mergeRequest.iid}] High-priority merge request is merged`, 265 | ); 266 | return; 267 | } 268 | } 269 | console.log( 270 | `[loop][MR][${mergeRequest.iid}] High-priority merge request is not acceptable in this moment.`, 271 | ); 272 | } 273 | 274 | console.log( 275 | `[loop][MR][${mergeRequest.iid}] Adding job to the queue with ${jobPriority} priority.`, 276 | ); 277 | worker.registerJobToQueue( 278 | formatQueueId(mergeRequest), 279 | { 280 | projectName: mergeRequest.references.full.split('!')[0], 281 | }, 282 | jobPriority, 283 | jobId, 284 | async ({ success, job }) => { 285 | const result = await acceptMergeRequest( 286 | job, 287 | gitlabApi, 288 | mergeRequest.target_project_id, 289 | mergeRequest.iid, 290 | user, 291 | config, 292 | ); 293 | if (result === 'continue') { 294 | return; 295 | } 296 | 297 | console.log(`Finishing job: ${JSON.stringify(result)}`); 298 | if ('mergeRequestTodo' in mergeRequestData) { 299 | await gitlabApi.markTodoAsDone(mergeRequestData.mergeRequestTodo.id); 300 | } 301 | success(); 302 | }, 303 | jobInfo, 304 | ); 305 | 306 | await setBotLabels(gitlabApi, mergeRequest, [BotLabels.InMergeQueue]); 307 | }; 308 | -------------------------------------------------------------------------------- /server/src/PipelineCanceller.ts: -------------------------------------------------------------------------------- 1 | import { GitlabApi, MergeRequestInfo, PipelineStatus, User } from './GitlabApi'; 2 | 3 | export const tryCancelPipeline = async ( 4 | gitlabApi: GitlabApi, 5 | mergeRequestInfo: MergeRequestInfo, 6 | user: User, 7 | ): Promise => { 8 | if (mergeRequestInfo.head_pipeline === null) { 9 | return; 10 | } 11 | 12 | if ( 13 | mergeRequestInfo.head_pipeline.status !== PipelineStatus.Running && 14 | mergeRequestInfo.head_pipeline.status !== PipelineStatus.Pending 15 | ) { 16 | return; 17 | } 18 | 19 | const mergeRequestPipeline = await gitlabApi.getPipeline( 20 | mergeRequestInfo.target_project_id, 21 | mergeRequestInfo.head_pipeline.id, 22 | ); 23 | if (mergeRequestPipeline.user.id !== user.id) { 24 | return; 25 | } 26 | 27 | await gitlabApi.cancelPipeline( 28 | mergeRequestInfo.target_project_id, 29 | mergeRequestInfo.head_pipeline.id, 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /server/src/Queue.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './Config'; 2 | import { Job, JobFunction } from './Job'; 3 | import { JobInfo, JobPriority, JobStatus, QueueInfo } from './generated/graphqlgen'; 4 | 5 | export class Queue { 6 | private _stop: boolean = true; 7 | private timer: NodeJS.Timeout | null = null; 8 | private jobs: Job[] = []; 9 | private onStop: (() => unknown) | null = null; 10 | 11 | private readonly config: Config; 12 | private readonly info: QueueInfo; 13 | private readonly onChange: () => unknown; 14 | 15 | constructor(config: Config, info: QueueInfo, onChange: () => unknown) { 16 | this.config = config; 17 | this.info = info; 18 | this.onChange = onChange; 19 | } 20 | 21 | public start(): void { 22 | if (!this._stop) { 23 | return; 24 | } 25 | 26 | console.log(`[queue][${this.info.projectName}] Starting`); 27 | this._stop = false; 28 | this.loop().catch((error) => console.error(`Error: ${JSON.stringify(error)}`)); 29 | } 30 | 31 | private async loop(): Promise { 32 | await this.tick() 33 | .catch((error) => console.error(`Error: ${JSON.stringify(error)}`)) 34 | .then(() => { 35 | if (this._stop) { 36 | console.log(`[queue][${this.info.projectName}] Stopped`); 37 | if (this.onStop) { 38 | this.onStop(); 39 | this.onStop = null; 40 | } 41 | return; 42 | } 43 | 44 | this.timer = setTimeout(() => { 45 | this.timer = null; 46 | this.loop().catch((error) => console.error(`Error: ${JSON.stringify(error)}`)); 47 | }, this.config.CI_CHECK_INTERVAL); 48 | }); 49 | } 50 | 51 | public async stop(): Promise { 52 | if (this._stop || this.onStop !== null) { 53 | return; 54 | } 55 | 56 | console.log(`[queue][${this.info.projectName}] Shutting down`); 57 | if (this.timer !== null) { 58 | clearTimeout(this.timer); 59 | this.timer = null; 60 | console.log(`[queue][${this.info.projectName}] Stopped`); 61 | return; 62 | } 63 | 64 | return new Promise((resolve) => { 65 | this.onStop = resolve; 66 | this._stop = true; 67 | }); 68 | } 69 | 70 | public isEmpty(): boolean { 71 | return this.jobs.length === 0; 72 | } 73 | 74 | private findHighPrioritizedJob(): Job | null { 75 | for (let priority of [JobPriority.HIGH, JobPriority.NORMAL]) { 76 | const job = this.jobs.find((job) => job.priority === priority); 77 | if (job !== undefined) { 78 | return job; 79 | } 80 | } 81 | 82 | return null; 83 | } 84 | 85 | public async tick(): Promise { 86 | console.log(`[queue][${this.info.projectName}] Tick`); 87 | 88 | while (true) { 89 | let exitTick = true; 90 | 91 | const job = this.findHighPrioritizedJob(); 92 | if (job === null) { 93 | return; 94 | } 95 | 96 | if (this.timer !== null) { 97 | this.timer.refresh(); 98 | } 99 | 100 | await job.run({ 101 | success: () => { 102 | this.removeJob(job.id); 103 | exitTick = false; 104 | }, 105 | job, 106 | }); 107 | 108 | if (this.timer !== null) { 109 | this.timer.refresh(); 110 | } 111 | 112 | if (exitTick) { 113 | return; 114 | } 115 | } 116 | } 117 | 118 | public getData() { 119 | return { 120 | info: this.info, 121 | jobs: this.jobs.map((job) => job.getData()), 122 | }; 123 | } 124 | 125 | public setJobPriority(jobId: string, jobPriority: JobPriority): void { 126 | const currentJob = this.findJob(jobId); 127 | if (currentJob === null) { 128 | return; 129 | } 130 | 131 | currentJob.updatePriority(jobPriority); 132 | } 133 | 134 | public removeJob(jobId: string): void { 135 | let jobIndex = this.jobs.findIndex((job) => job.id === jobId); 136 | if (jobIndex === -1) { 137 | return; 138 | } 139 | 140 | this.jobs.splice(jobIndex, 1); 141 | this.onChange(); 142 | } 143 | 144 | public findPriorityByJobId(jobId: string): JobPriority | null { 145 | const job = this.findJob(jobId); 146 | if (job !== null) { 147 | return job.priority; 148 | } 149 | 150 | return null; 151 | } 152 | 153 | public findJob(jobId: string): Job | null { 154 | let job = this.jobs.find((job) => job.id === jobId); 155 | if (job !== undefined) { 156 | return job; 157 | } 158 | 159 | return null; 160 | } 161 | 162 | public registerJob( 163 | jobId: string, 164 | job: JobFunction, 165 | jobPriority: JobPriority, 166 | jobInfo: JobInfo, 167 | ): void { 168 | const currentJob = this.findJob(jobId); 169 | if (currentJob !== null) { 170 | currentJob.updateStatus(JobStatus.WAITING); 171 | currentJob.updateInfo(jobInfo); 172 | return; 173 | } 174 | 175 | this.jobs.push(new Job(jobId, job, jobInfo, jobPriority, this.onChange)); 176 | 177 | this.onChange(); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /server/src/SendNote.ts: -------------------------------------------------------------------------------- 1 | import { GitlabApi, MergeRequest } from './GitlabApi'; 2 | 3 | export const sendNote = ( 4 | gitlabApi: GitlabApi, 5 | mergeRequest: MergeRequest, 6 | body: string, 7 | ): Promise => 8 | gitlabApi.createMergeRequestNote(mergeRequest.target_project_id, mergeRequest.iid, body); 9 | -------------------------------------------------------------------------------- /server/src/Types.ts: -------------------------------------------------------------------------------- 1 | export enum AppEvent { 2 | QUEUE_CHANGED = 'queue_changed', 3 | WEB_HOOK_HISTORY_CHANGED = 'web_hook_history_changed', 4 | } 5 | -------------------------------------------------------------------------------- /server/src/Utils.ts: -------------------------------------------------------------------------------- 1 | import { MergeRequest } from './GitlabApi'; 2 | import { QueueId } from './Worker'; 3 | 4 | export const sleep = (ms: number): Promise => 5 | new Promise((resolve) => setTimeout(resolve, ms)); 6 | 7 | export const formatQueueId = ( 8 | mergeRequest: Pick, 9 | ) => `${mergeRequest.target_project_id}:${mergeRequest.target_branch}` as QueueId; 10 | -------------------------------------------------------------------------------- /server/src/WebHookHistory.ts: -------------------------------------------------------------------------------- 1 | export class WebHookHistory { 2 | private isHistoryFull = false; 3 | private historyIndex = 0; 4 | private readonly history: TItem[] = []; 5 | 6 | public constructor(historyLength: number) { 7 | this.history.length = historyLength; 8 | } 9 | 10 | public add(item: TItem) { 11 | this.history[this.historyIndex] = item; 12 | this.increaseIndex(); 13 | } 14 | 15 | public getHistory(): TItem[] { 16 | if (!this.isHistoryFull) { 17 | return this.history.slice(0, this.historyIndex).reverse(); 18 | } 19 | 20 | return [ 21 | ...this.history.slice(this.historyIndex), 22 | ...this.history.slice(0, this.historyIndex), 23 | ].reverse(); 24 | } 25 | 26 | private increaseIndex() { 27 | this.historyIndex = this.historyIndex + 1; 28 | if (this.historyIndex >= this.history.length) { 29 | this.historyIndex = 0; 30 | this.isHistoryFull = true; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/src/WebHookServer.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { v4 as uuid } from 'uuid'; 3 | import path from 'path'; 4 | import bodyParser from 'body-parser'; 5 | import { GitlabApi, MergeState, User } from './GitlabApi'; 6 | import { Worker } from './Worker'; 7 | import { prepareMergeRequestForMerge } from './MergeRequestReceiver'; 8 | import { ApolloServer } from '@apollo/server'; 9 | import { expressMiddleware } from '@apollo/server/express4'; 10 | import { PubSub } from 'graphql-subscriptions'; 11 | import http from 'http'; 12 | import { AppEvent } from './Types'; 13 | import { Resolvers, typeDefs, WebHookHistoryStatus } from './generated/graphqlgen'; 14 | import { Config } from './Config'; 15 | import { WebSocketServer } from 'ws'; 16 | import { useServer } from 'graphql-ws/lib/use/ws'; 17 | import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; 18 | import { makeExecutableSchema } from '@graphql-tools/schema'; 19 | 20 | import { assignToAuthorAndResetLabels } from './AssignToAuthor'; 21 | import { formatQueueId } from './Utils'; 22 | import { WebHookHistory } from './WebHookHistory'; 23 | 24 | interface MergeRequestAssignee { 25 | username: string; 26 | } 27 | 28 | interface MergeRequestHook { 29 | object_attributes: { 30 | id: number; 31 | iid: number; 32 | state: MergeState; 33 | target_project_id: number; 34 | target_branch: string; 35 | }; 36 | labels: string[]; 37 | assignees?: MergeRequestAssignee[]; 38 | } 39 | 40 | enum Events { 41 | MergeRequest = 'Merge Request Hook', 42 | } 43 | 44 | const containsAssignedUser = (mergeRequest: MergeRequestHook, user: User): boolean => { 45 | console.log('mergeRequest.assignees', mergeRequest.assignees); 46 | const userNames = mergeRequest.assignees?.map((assignee) => assignee.username); 47 | return userNames?.includes(user.username) ?? false; 48 | }; 49 | 50 | type WebHookHistoryMessage = { 51 | id: string; 52 | status: WebHookHistoryStatus; 53 | event?: string; 54 | data?: string; 55 | createdAt: number; 56 | }; 57 | 58 | const processMergeRequestHook = async ( 59 | gitlabApi: GitlabApi, 60 | worker: Worker, 61 | user: User, 62 | data: MergeRequestHook, 63 | config: Config, 64 | ) => { 65 | if (data.object_attributes.state !== MergeState.Opened) { 66 | return; 67 | } 68 | 69 | if (containsAssignedUser(data, user)) { 70 | const mergeRequest = await gitlabApi.getMergeRequest( 71 | data.object_attributes.target_project_id, 72 | data.object_attributes.iid, 73 | ); 74 | await prepareMergeRequestForMerge(gitlabApi, user, worker, config, { mergeRequest }); 75 | return; 76 | } 77 | 78 | const jobId = `accept-merge-${data.object_attributes.id}`; 79 | const currentJob = worker.findJob(formatQueueId(data.object_attributes), jobId); 80 | if (currentJob !== null) { 81 | await worker.removeJobFromQueue(formatQueueId(data.object_attributes), jobId); 82 | } 83 | }; 84 | 85 | export class WebHookServer { 86 | private started: boolean = false; 87 | private httpServer: http.Server | null = null; 88 | 89 | private readonly pubSub: PubSub; 90 | private readonly gitlabApi: GitlabApi; 91 | private readonly worker: Worker; 92 | private readonly user: User; 93 | private readonly config: Config; 94 | private readonly webHookHistory: WebHookHistory; 95 | 96 | constructor(pubSub: PubSub, gitlabApi: GitlabApi, worker: Worker, user: User, config: Config) { 97 | this.pubSub = pubSub; 98 | this.gitlabApi = gitlabApi; 99 | this.worker = worker; 100 | this.user = user; 101 | this.config = config; 102 | this.webHookHistory = new WebHookHistory(config.WEB_HOOK_HISTORY_SIZE); 103 | } 104 | 105 | public stop(): Promise { 106 | if (!this.started) { 107 | return Promise.resolve(); 108 | } 109 | 110 | return new Promise((resolve, reject) => { 111 | console.log('[api] Shutting down'); 112 | this.started = false; 113 | 114 | setTimeout(() => { 115 | if (this.httpServer === null) { 116 | console.log('[api] Stopped'); 117 | return resolve(); 118 | } 119 | 120 | this.httpServer.close((error) => { 121 | if (error) { 122 | return reject(error); 123 | } 124 | 125 | this.httpServer = null; 126 | console.log('[api] Stopped'); 127 | return resolve(); 128 | }); 129 | }, 5000); 130 | }); 131 | } 132 | 133 | public start(): Promise { 134 | return new Promise((resolve) => { 135 | const app = express(); 136 | this.httpServer = http.createServer(app); 137 | 138 | app.get('/healthz', (req, res) => { 139 | if (this.started) { 140 | res.send('OK'); 141 | return; 142 | } 143 | 144 | res.status(502); 145 | res.send('Failed'); 146 | }); 147 | 148 | app.use( 149 | express.static(path.join(process.cwd(), 'dashboard/out'), { 150 | index: 'index.html', 151 | extensions: ['html'], 152 | }), 153 | ); 154 | 155 | app.use( 156 | bodyParser.json({ 157 | limit: '2mb', 158 | }), 159 | ); 160 | app.post('/', async (req, res) => { 161 | let webHookHistoryMessage: WebHookHistoryMessage = { 162 | id: uuid(), 163 | status: WebHookHistoryStatus.UNAUTHORIZED, 164 | createdAt: Math.floor(Date.now() / 1000), 165 | }; 166 | 167 | try { 168 | const token = req.headers['x-gitlab-token']; 169 | if (!token || token !== this.config.WEB_HOOK_TOKEN) { 170 | res.sendStatus(405); 171 | res.send(`No X-Gitlab-Token found on request or the token did not match`); 172 | return; 173 | } 174 | 175 | const event = req.headers['x-gitlab-event']; 176 | if (!(typeof event === 'string')) { 177 | webHookHistoryMessage.status = WebHookHistoryStatus.INVALID_EVENT; 178 | 179 | res.sendStatus(405); 180 | res.send(`No X-Gitlab-Event found on request`); 181 | return; 182 | } 183 | 184 | const data = req.body; 185 | 186 | webHookHistoryMessage = { 187 | ...webHookHistoryMessage, 188 | status: WebHookHistoryStatus.SKIPPED, 189 | data: JSON.stringify(data), 190 | event, 191 | }; 192 | 193 | if (event === Events.MergeRequest) { 194 | webHookHistoryMessage.status = WebHookHistoryStatus.SUCCESS; 195 | await processMergeRequestHook( 196 | this.gitlabApi, 197 | this.worker, 198 | this.user, 199 | data as MergeRequestHook, 200 | this.config, 201 | ); 202 | } 203 | 204 | res.send('ok'); 205 | } finally { 206 | this.webHookHistory.add(webHookHistoryMessage); 207 | await this.pubSub.publish(AppEvent.WEB_HOOK_HISTORY_CHANGED, {}); 208 | } 209 | }); 210 | 211 | const mapUser = (user: User) => ({ 212 | id: user.id, 213 | name: user.name, 214 | username: user.username, 215 | email: user.email, 216 | webUrl: user.web_url, 217 | avatarUrl: user.avatar_url, 218 | }); 219 | 220 | const resolvers: Resolvers = { 221 | Query: { 222 | user: async (parent, args) => { 223 | const user = await this.gitlabApi.getUser(args.input.id); 224 | console.log('user', user); 225 | return mapUser(user); 226 | }, 227 | me: () => mapUser(this.user), 228 | queues: () => this.worker.getQueuesData(), 229 | }, 230 | Subscription: { 231 | queues: { 232 | resolve: () => this.worker.getQueuesData(), 233 | subscribe: () => { 234 | const listenerName = `subscription_${uuid()}`; 235 | setTimeout(() => { 236 | this.pubSub 237 | .publish(listenerName, {}) 238 | .catch((error) => 239 | console.log(`Error: ${JSON.stringify(error)}`), 240 | ); 241 | }, 1); 242 | return this.pubSub.asyncIterator([ 243 | AppEvent.QUEUE_CHANGED, 244 | listenerName, 245 | ]) as unknown as AsyncIterable; 246 | }, 247 | }, 248 | webHookHistory: { 249 | resolve: () => this.webHookHistory.getHistory(), 250 | subscribe: () => { 251 | const listenerName = `subscription_${uuid()}`; 252 | setTimeout(() => { 253 | this.pubSub 254 | .publish(listenerName, {}) 255 | .catch((error) => 256 | console.log(`Error: ${JSON.stringify(error)}`), 257 | ); 258 | }, 1); 259 | return this.pubSub.asyncIterator([ 260 | AppEvent.WEB_HOOK_HISTORY_CHANGED, 261 | listenerName, 262 | ]) as unknown as AsyncIterable; 263 | }, 264 | }, 265 | }, 266 | Mutation: { 267 | unassign: async (parent, { input }) => { 268 | const mergeRequest = await this.gitlabApi.getMergeRequest( 269 | input.projectId, 270 | input.mergeRequestIid, 271 | ); 272 | await assignToAuthorAndResetLabels(this.gitlabApi, mergeRequest, this.user); 273 | const jobId = `accept-merge-${mergeRequest.id}`; 274 | await this.worker.removeJobFromQueue(formatQueueId(mergeRequest), jobId); 275 | 276 | return null; 277 | }, 278 | }, 279 | }; 280 | 281 | const wsServer = new WebSocketServer({ 282 | server: this.httpServer, 283 | path: '/graphql', 284 | }); 285 | 286 | const schema = makeExecutableSchema({ typeDefs: typeDefs, resolvers }); 287 | const serverCleanup = useServer({ schema }, wsServer); 288 | 289 | const server = new ApolloServer({ 290 | schema, 291 | plugins: [ 292 | ApolloServerPluginDrainHttpServer({ httpServer: this.httpServer }), 293 | { 294 | async serverWillStart() { 295 | return { 296 | async drainServer() { 297 | await serverCleanup.dispose(); 298 | }, 299 | }; 300 | }, 301 | }, 302 | ], 303 | }); 304 | 305 | const httpServer = this.httpServer; 306 | 307 | (async () => { 308 | await server.start(); 309 | app.use('/graphql', expressMiddleware(server)); 310 | 311 | httpServer.listen(this.config.HTTP_SERVER_PORT, () => { 312 | console.log( 313 | `[api] API server is listening on port ${this.config.HTTP_SERVER_PORT}`, 314 | ); 315 | this.started = true; 316 | return resolve(); 317 | }); 318 | })(); 319 | }); 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /server/src/Worker.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from './Queue'; 2 | import { PubSub } from 'graphql-subscriptions'; 3 | import { AppEvent } from './Types'; 4 | import { Config } from './Config'; 5 | import { Job, JobFunction } from './Job'; 6 | import { JobInfo, JobPriority, QueueInfo, Queue as GQLQueue } from './generated/graphqlgen'; 7 | 8 | export type QueueId = string & { _kind: 'QueueId' }; 9 | 10 | export class Worker { 11 | private _stop: boolean = true; 12 | private queues = new Map(); 13 | 14 | private readonly pubSub: PubSub; 15 | private readonly config: Config; 16 | 17 | constructor(pubSub: PubSub, config: Config) { 18 | this.pubSub = pubSub; 19 | this.config = config; 20 | } 21 | 22 | public getQueuesData(): GQLQueue[] { 23 | return Array.from(this.queues.entries()).map(([key, value]) => ({ 24 | name: key, 25 | ...value.getData(), 26 | })); 27 | } 28 | 29 | public start(): void { 30 | if (!this._stop) { 31 | return; 32 | } 33 | 34 | console.log('[worker] Starting'); 35 | this._stop = false; 36 | 37 | return Array.from(this.queues.values()).forEach((queue) => { 38 | queue.start(); 39 | }); 40 | } 41 | 42 | public async stop(): Promise { 43 | if (this._stop) { 44 | return; 45 | } 46 | 47 | console.log('[worker] Shutting down'); 48 | this._stop = true; 49 | await Promise.all(Array.from(this.queues.values()).map((queue) => queue.stop())); 50 | console.log('[worker] Stopped'); 51 | } 52 | 53 | public findJobPriorityInQueue(queueId: QueueId, jobId: string): JobPriority | null { 54 | const queue = this.queues.get(queueId); 55 | if (queue === undefined) { 56 | return null; 57 | } 58 | 59 | return queue.findPriorityByJobId(jobId); 60 | } 61 | 62 | public findJob(queueId: QueueId, jobId: string): Job | null { 63 | const queue = this.queues.get(queueId); 64 | if (queue === undefined) { 65 | return null; 66 | } 67 | 68 | return queue.findJob(jobId); 69 | } 70 | 71 | public setJobPriority(queueId: QueueId, jobId: string, jobPriority: JobPriority): void { 72 | const queue = this.queues.get(queueId); 73 | if (queue === undefined) { 74 | return; 75 | } 76 | 77 | queue.setJobPriority(jobId, jobPriority); 78 | } 79 | 80 | public async removeJobFromQueue(queueId: QueueId, jobId: string) { 81 | const queue = this.queues.get(queueId); 82 | if (queue === undefined) { 83 | return; 84 | } 85 | 86 | queue.removeJob(jobId); 87 | } 88 | 89 | public registerJobToQueue>( 90 | queueId: QueueId, 91 | queueInfo: QueueInfo, 92 | jobPriority: JobPriority, 93 | jobId: string, 94 | job: JobFunction, 95 | jobInfo: JobInfo, 96 | ): void { 97 | let queue = this.queues.get(queueId); 98 | if (queue === undefined) { 99 | console.log(`[worker][${queueId}] Creating queue`); 100 | queue = new Queue(this.config, queueInfo, async () => { 101 | Array.from(this.queues.entries()).map(([key, value]) => { 102 | if (value.isEmpty()) { 103 | console.log(`[worker][${queueId}] Deleting queue`); 104 | value.stop(); 105 | this.queues.delete(key); 106 | } 107 | }); 108 | 109 | await this.pubSub.publish(AppEvent.QUEUE_CHANGED, {}); 110 | }); 111 | 112 | this.queues.set(queueId, queue); 113 | 114 | if (!this._stop) { 115 | queue.start(); 116 | } 117 | } 118 | 119 | queue.registerJob(jobId, job, jobPriority, jobInfo); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /server/src/__tests__/Queue.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from '../Queue'; 2 | import { defaultConfig } from '../Config'; 3 | import { JobInfo, JobPriority, QueueInfo } from '../generated/graphqlgen'; 4 | 5 | const jobInfoMock: JobInfo = { 6 | mergeRequest: { 7 | title: 'title', 8 | webUrl: 'webUrl', 9 | projectId: 1, 10 | authorId: 1, 11 | iid: 2, 12 | }, 13 | }; 14 | 15 | const queueInfoMock: QueueInfo = { 16 | projectName: 'test', 17 | }; 18 | 19 | const onChange = jest.fn(); 20 | 21 | it('runs two jobs', async () => { 22 | const job1 = jest.fn(({ success }) => { 23 | success(); 24 | }); 25 | const job2 = jest.fn(); 26 | const job3 = jest.fn(); 27 | 28 | const queue = new Queue(defaultConfig, queueInfoMock, onChange); 29 | 30 | queue.registerJob('job1', job1, JobPriority.NORMAL, jobInfoMock); 31 | queue.registerJob('job2', job2, JobPriority.NORMAL, jobInfoMock); 32 | queue.registerJob('job3', job3, JobPriority.NORMAL, jobInfoMock); 33 | 34 | expect(job1.mock.calls.length).toBe(0); 35 | expect(job2.mock.calls.length).toBe(0); 36 | expect(job3.mock.calls.length).toBe(0); 37 | await queue.tick(); 38 | expect(job1.mock.calls.length).toBe(1); 39 | expect(job2.mock.calls.length).toBe(1); 40 | expect(job3.mock.calls.length).toBe(0); 41 | await queue.tick(); 42 | expect(job1.mock.calls.length).toBe(1); 43 | expect(job2.mock.calls.length).toBe(2); 44 | expect(job3.mock.calls.length).toBe(0); 45 | await queue.tick(); 46 | expect(job1.mock.calls.length).toBe(1); 47 | expect(job2.mock.calls.length).toBe(3); 48 | expect(job3.mock.calls.length).toBe(0); 49 | }); 50 | -------------------------------------------------------------------------------- /server/src/__tests__/WebHookHistory.ts: -------------------------------------------------------------------------------- 1 | import { WebHookHistory } from '../WebHookHistory'; 2 | 3 | describe('WebHookHistory', () => { 4 | it('should return values', async () => { 5 | const webHookHistory = new WebHookHistory(3); 6 | webHookHistory.add('a'); 7 | webHookHistory.add('b'); 8 | 9 | expect(webHookHistory.getHistory()).toStrictEqual(['b', 'a']); 10 | }); 11 | 12 | it('should return last three values', async () => { 13 | const webHookHistory = new WebHookHistory(3); 14 | webHookHistory.add('a'); 15 | webHookHistory.add('b'); 16 | webHookHistory.add('c'); 17 | webHookHistory.add('d'); 18 | 19 | expect(webHookHistory.getHistory()).toStrictEqual(['d', 'c', 'b']); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /server/src/__tests__/Worker.ts: -------------------------------------------------------------------------------- 1 | import { QueueId, Worker } from '../Worker'; 2 | import { defaultConfig } from '../Config'; 3 | import { PubSub } from 'graphql-subscriptions'; 4 | import { JobInfo, JobPriority } from '../generated/graphqlgen'; 5 | 6 | const jobInfoMock: JobInfo = { 7 | mergeRequest: { 8 | title: 'title', 9 | webUrl: 'webUrl', 10 | projectId: 1, 11 | authorId: 1, 12 | iid: 2, 13 | }, 14 | }; 15 | 16 | const queueInfoMock = { 17 | projectName: 'test', 18 | }; 19 | 20 | const config = { 21 | ...defaultConfig, 22 | GITLAB_AUTH_TOKEN: 'foo', 23 | }; 24 | 25 | it('runs two jobs', async () => { 26 | const job1 = jest.fn(); 27 | const job2 = jest.fn(); 28 | 29 | const pubSub = new PubSub(); 30 | const worker = new Worker(pubSub, config); 31 | 32 | expect(worker.findJobPriorityInQueue('1' as QueueId, 'fooJob')).toBe(null); 33 | expect(worker.findJobPriorityInQueue('2' as QueueId, 'fooJob')).toBe(null); 34 | 35 | worker.registerJobToQueue( 36 | '1' as QueueId, 37 | queueInfoMock, 38 | JobPriority.NORMAL, 39 | 'fooJob', 40 | job1, 41 | jobInfoMock, 42 | ); 43 | 44 | expect(worker.findJobPriorityInQueue('1' as QueueId, 'fooJob')).toBe(JobPriority.NORMAL); 45 | expect(worker.findJobPriorityInQueue('2' as QueueId, 'fooJob')).toBe(null); 46 | 47 | worker.registerJobToQueue( 48 | '2' as QueueId, 49 | queueInfoMock, 50 | JobPriority.NORMAL, 51 | 'fooJob', 52 | job2, 53 | jobInfoMock, 54 | ); 55 | 56 | expect(worker.findJobPriorityInQueue('1' as QueueId, 'fooJob')).toBe(JobPriority.NORMAL); 57 | expect(worker.findJobPriorityInQueue('2' as QueueId, 'fooJob')).toBe(JobPriority.NORMAL); 58 | 59 | expect(job1.mock.calls.length).toBe(0); 60 | expect(job2.mock.calls.length).toBe(0); 61 | }); 62 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import * as env from 'env-var'; 3 | import { GitlabApi } from './GitlabApi'; 4 | import { Worker } from './Worker'; 5 | import { getConfig } from './Config'; 6 | import { PubSub } from 'graphql-subscriptions'; 7 | import { MergeRequestCheckerLoop } from './MergeRequestCheckerLoop'; 8 | import { WebHookServer } from './WebHookServer'; 9 | 10 | const SENTRY_DSN = env.get('SENTRY_DSN').default('').asString(); 11 | if (SENTRY_DSN !== '') { 12 | Sentry.init({ dsn: SENTRY_DSN }); 13 | } 14 | 15 | const config = getConfig(); 16 | const gitlabApi = new GitlabApi( 17 | config.GITLAB_URL, 18 | config.GITLAB_AUTH_TOKEN, 19 | config.HTTP_PROXY !== '' ? config.HTTP_PROXY : undefined, 20 | ); 21 | const pubSub = new PubSub(); 22 | const worker = new Worker(pubSub, config); 23 | 24 | (async () => { 25 | console.log(`Configuration:`); 26 | console.log( 27 | JSON.stringify( 28 | { 29 | ...config, 30 | GITLAB_AUTH_TOKEN: '*******', 31 | }, 32 | null, 33 | 4, 34 | ), 35 | ); 36 | 37 | const user = await gitlabApi.getMe(); 38 | 39 | console.log(`[bot] Hi, I'm ${user.name}. I'll accept merge request assigned to me.`); 40 | 41 | const shutdownHandlers: (() => Promise)[] = []; 42 | const webHookServer = new WebHookServer(pubSub, gitlabApi, worker, user, config); 43 | 44 | if (config.MR_CHECK_INTERVAL > 0) { 45 | const mergeRequestCheckerLoop = new MergeRequestCheckerLoop( 46 | gitlabApi, 47 | config, 48 | user, 49 | worker, 50 | ); 51 | mergeRequestCheckerLoop.start(); 52 | shutdownHandlers.push(() => mergeRequestCheckerLoop.stop()); 53 | } else { 54 | console.log( 55 | `[bot] The merge request checker loop is disabled, because MR_CHECK_INTERVAL is set to zero.`, 56 | ); 57 | } 58 | 59 | worker.start(); 60 | shutdownHandlers.push(() => worker.stop()); 61 | 62 | if (config.HTTP_SERVER_ENABLE) { 63 | await webHookServer.start(); 64 | } 65 | 66 | const shutdownHandler = async (signal: NodeJS.Signals) => { 67 | console.log(`[bot] Caught ${signal} signal`); 68 | 69 | const promises: Promise[] = shutdownHandlers.map((shutdownHandler) => 70 | shutdownHandler(), 71 | ); 72 | 73 | if (config.HTTP_SERVER_ENABLE) { 74 | promises.push(webHookServer.stop()); 75 | } 76 | 77 | Promise.all(promises).finally(() => { 78 | process.exit(0); 79 | }); 80 | }; 81 | 82 | process.on('exit', () => { 83 | console.log(`[bot] App stopped!`); 84 | }); 85 | process.on('SIGINT', shutdownHandler); 86 | process.on('SIGTERM', shutdownHandler); 87 | })(); 88 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "esModuleInterop": true, 6 | "target": "ES2022", 7 | "sourceMap": true, 8 | "strict": true, 9 | "noImplicitReturns": true, 10 | "baseUrl": ".", 11 | "skipLibCheck": true, 12 | "lib": ["ES2023"], 13 | "paths": { 14 | "*": ["node_modules/*", "src/types/*"] 15 | } 16 | }, 17 | "include": ["src/**/*", "../schema.graphql"] 18 | } 19 | -------------------------------------------------------------------------------- /server/tsconfig.webpack-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2019", 5 | "esModuleInterop": true, 6 | "resolveJsonModule": true 7 | } 8 | } 9 | --------------------------------------------------------------------------------