├── .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 | 
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 |
20 |
21 |
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 |
--------------------------------------------------------------------------------