forked from git-workflow/autogits
Compare commits
259 Commits
| Author | SHA256 | Date | |
|---|---|---|---|
| 44bd6c23e5 | |||
| 17bfc36801 | |||
| 73d8c3e97f | |||
| 64e5b51488 | |||
| 1fe6bd98d4 | |||
| e859ed1b54 | |||
| 1ad2e1738c | |||
| a6d5d4c79d | |||
| f5d6f50b32 | |||
| edd8c67fc9 | |||
| 87633e7508 | |||
| 56ce07514a | |||
| 1d95b4cf0f | |||
| cc69a9348c | |||
| 5b5bb9a5bc | |||
|
|
2f39fc9836 | ||
| 38f4c44fd0 | |||
| 605d3dee06 | |||
| 6f26bcdccc | |||
| fffdf4fad3 | |||
| f6d2239f4d | |||
| 913fb7c046 | |||
| 79318dc169 | |||
| 377ed1c37f | |||
| 51b0487b29 | |||
| 49e32c0ab1 | |||
| 01e4f5f59e | |||
| 19d9fc5f1e | |||
| c4e184140a | |||
| 56c492ccdf | |||
| 3a6009a5a3 | |||
| 2c4d25a5eb | |||
| 052ab37412 | |||
| 925f546272 | |||
| 71fd32a707 | |||
| 581131bdc8 | |||
| 495ed349ea | |||
| 350a255d6e | |||
| e3087e46c2 | |||
| ae6b638df6 | |||
| 2c73cc683a | |||
| 32adfb1111 | |||
| fe8fcbae96 | |||
| 5756f7ceea | |||
| 2be0f808d2 | |||
| 7a0f651eaf | |||
| 2e47104b17 | |||
| 76bfa612c5 | |||
| 71aa0813ad | |||
| cc675c1b24 | |||
| 44e4941120 | |||
| 86acfa6871 | |||
| 7f09b2d2d3 | |||
| f3a37f1158 | |||
| 9d6db86318 | |||
| e11993c81f | |||
| 4bd259a2a0 | |||
| 162ae11cdd | |||
| 8431b47322 | |||
| 3ed5ecc3f0 | |||
| d08ab3efd6 | |||
| a4f6628e52 | |||
| 25073dd619 | |||
| 4293181b4e | |||
| 551a4ef577 | |||
| 6afb18fc58 | |||
| f310220261 | |||
| ef7c0c1cea | |||
| 27230fa03b | |||
| c52d40b760 | |||
| d3ba579a8b | |||
| 9ef8209622 | |||
| ba66dd868e | |||
| 17755fa2b5 | |||
| f94d3a8942 | |||
| 20e1109602 | |||
| c25d3be44e | |||
| 8db558891a | |||
| 0e06ba5993 | |||
| 736769d630 | |||
| 93c970d0dd | |||
| 5544a65947 | |||
| 918723d57b | |||
| a418b48809 | |||
|
55846562c1
|
|||
|
95c7770cad
|
|||
|
1b900e3202
|
|||
|
d083acfd1c
|
|||
|
244160e20e
|
|||
| ed2847a2c6 | |||
| 1457caa64b | |||
| b9a38c1724 | |||
| 74edad5d3e | |||
|
|
e5cad365ee
|
||
|
|
53851ba10f
|
||
|
|
056e5208c8
|
||
|
|
af142fdb15
|
||
|
|
5ce92beb52
|
||
|
|
ae379ec408
|
||
| 458837b007 | |||
| a3feab6f7e | |||
| fa647ab2d8 | |||
| 19902813b5 | |||
| 23a7f310c5 | |||
| 58d1f2de91 | |||
| d623844411 | |||
| 04825b552e | |||
| ca7966f3e0 | |||
| 0c47ca4d32 | |||
| 7bad8eb5a9 | |||
| c2c60b77e5 | |||
| 76b5a5dc0d | |||
| 58da491049 | |||
| 626bead304 | |||
| 30bac996f4 | |||
| 9adc718b6f | |||
| 070f45bc25 | |||
| d061f29699 | |||
| f6fd96881d | |||
| 2be785676a | |||
| 1b9ee2d46a | |||
| b7bbafacf8 | |||
| 240896f101 | |||
| a7b326fceb | |||
| 76ed03f86f | |||
| 1af2f53755 | |||
| 0de9071f92 | |||
| 855faea659 | |||
| dbd581ffef | |||
| 1390225614 | |||
| a03491f75c | |||
| 2092fc4f42 | |||
| d2973f4792 | |||
| 58022c6edc | |||
| 994e6b3ca2 | |||
| 6414336ee6 | |||
| 1104581eb6 | |||
| 6ad110e5d3 | |||
| e39ce302b8 | |||
| 3f216dc275 | |||
| 8af7e58534 | |||
| 043673d9ac | |||
| 73737be16a | |||
| 1d3ed81ac5 | |||
| 49c4784e70 | |||
| be15c86973 | |||
| 72857db561 | |||
| faf53aaae2 | |||
| 9e058101f0 | |||
|
|
4ae45d9913
|
||
| 56cf8293ed | |||
| fd5b3598bf | |||
| 9dd5a57b81 | |||
| 1cd385e227 | |||
| 3c20eb567b | |||
| ff7df44d37 | |||
| 1a19873f77 | |||
| 6a09bf021e | |||
| f2089f99fc | |||
| 10ea3a8f8f | |||
| 9faa6ead49 | |||
| 29cce5741a | |||
| 804e542c3f | |||
| 72899162b0 | |||
| 168a419bbe | |||
| 6a71641295 | |||
| 5addde0a71 | |||
| 90ea1c9463 | |||
| a4fb3e6151 | |||
| e2abbfcc63 | |||
| f6cb35acca | |||
| f4386c3d12 | |||
| f8594af8c6 | |||
| b8ef69a5a7 | |||
| c980b9f84d | |||
| 4651440457 | |||
| 7d58882ed8 | |||
| e90ba95869 | |||
| 1015e79026 | |||
| 833cb8b430 | |||
| a882ae283f | |||
| 305e90b254 | |||
| c80683182d | |||
| 51cd4da97b | |||
| cf71fe49d6 | |||
| 85a9a81804 | |||
| 72b979b587 | |||
| bb4350519b | |||
| 62658e23a7 | |||
| 6a1f92af12 | |||
| 24ed21ce7d | |||
| 46a187a60e | |||
| e0c7ea44ea | |||
| f013180c4b | |||
| b96b784b38 | |||
| 6864e95404 | |||
| 0ba4652595 | |||
| 8d0047649a | |||
| 2f180c264e | |||
| 7b87c4fd73 | |||
| 7d2233dd4a | |||
| c30ae5750b | |||
| ea2134c6e9 | |||
| b22f418595 | |||
| c4c9a16e7f | |||
| 5b1e6941c2 | |||
| 923bcd89db | |||
| e96f4d343b | |||
| bcb63fe1e9 | |||
| f4e78e53d3 | |||
| 082db173f3 | |||
| 7e055c3169 | |||
| 7e59e527d8 | |||
| 518845b3d8 | |||
| b091e0e98d | |||
| cedb7c0e76 | |||
| 7209f9f519 | |||
| bd5482d54e | |||
| bc95d50378 | |||
| fff996b497 | |||
| 2b67e6d80e | |||
| 5a875c19a0 | |||
| 538698373a | |||
| 84b8ca65ce | |||
| a02358e641 | |||
| 33c9bffc2e | |||
| 4894c0d90a | |||
| 090c291f8a | |||
| 42cedb6267 | |||
| f7229dfaf9 | |||
|
|
933ca9a3db
|
||
| 390cb89702 | |||
| 6cbeaef6f2 | |||
| d146fb8c4e | |||
| 7e78ee83c1 | |||
| 17e925bfd7 | |||
| 878df15e58 | |||
| c84af6286d | |||
| d2cbb8fd34 | |||
| 8436a49c5d | |||
| 106e36d6bf | |||
| 0ec4986163 | |||
| fb7f6adc98 | |||
| 231f29b065 | |||
| 3f3645a453 | |||
| 42e2713cd8 | |||
| 3d24dce5c0 | |||
| 0cefb45d8a | |||
| ddbb824006 | |||
| 69dac4ec31 | |||
| b7e03ab465 | |||
| 76aec3aabb | |||
| 19fb7e277b | |||
| 51261f1bc1 | |||
| 949810709d | |||
| c012570e89 | |||
| 44a3b15a7d | |||
|
e5d07f0ce6
|
|||
|
df9478a920
|
34
.gitea/workflows/go-generate-check.yaml
Normal file
34
.gitea/workflows/go-generate-check.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: go-generate-check
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- '**.go'
|
||||
- '**.mod'
|
||||
- '**.sum'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.go'
|
||||
- '**.mod'
|
||||
- '**.sum'
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
go-generate-check:
|
||||
name: go-generate-check
|
||||
container:
|
||||
image: registry.opensuse.org/devel/factory/git-workflow/containers/opensuse/bci/golang-extended:latest
|
||||
steps:
|
||||
- run: git clone --no-checkout --depth 1 ${{ gitea.server_url }}/${{ gitea.repository }} .
|
||||
- run: git fetch origin ${{ gitea.ref }}
|
||||
- run: git checkout FETCH_HEAD
|
||||
- run: go generate -C common
|
||||
- run: go generate -C workflow-pr
|
||||
- run: go generate -C workflow-pr/interfaces
|
||||
- run: git add -N .; git diff
|
||||
- run: |
|
||||
status=$(git status --short)
|
||||
if [[ -n "$status" ]]; then
|
||||
echo -e "$status"
|
||||
echo "Please commit the differences from running: go generate"
|
||||
false
|
||||
fi
|
||||
25
.gitea/workflows/go-generate-push.yaml
Normal file
25
.gitea/workflows/go-generate-push.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: go-generate-push
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
go-generate-push:
|
||||
name: go-generate-push
|
||||
container:
|
||||
image: registry.opensuse.org/devel/factory/git-workflow/containers/opensuse/bci/golang-extended:latest
|
||||
steps:
|
||||
- run: git clone --no-checkout --depth 1 ${{ gitea.server_url }}/${{ gitea.repository }} .
|
||||
- run: git fetch origin ${{ gitea.ref }}
|
||||
- run: git checkout FETCH_HEAD
|
||||
- run: go generate -C common
|
||||
- run: go generate -C workflow-pr
|
||||
- run: go generate -C workflow-pr/interfaces
|
||||
- run: |
|
||||
host=${{ gitea.server_url }}
|
||||
host=${host#https://}
|
||||
echo $host
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.GITEA_TOKEN }}@$host/${{ gitea.repository }}"
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "gitea_noreply@opensuse.org"
|
||||
- run: 'git status --short; git status --porcelain=2|grep --quiet -v . || ( git add .;git commit -m "CI run result of: go generate"; git push origin HEAD:${{ gitea.ref }} )'
|
||||
- run: git log -p FETCH_HEAD...HEAD
|
||||
- run: git log --numstat FETCH_HEAD...HEAD
|
||||
33
.gitea/workflows/go-vendor-check.yaml
Normal file
33
.gitea/workflows/go-vendor-check.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
name: go-vendor-check
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- '**.mod'
|
||||
- '**.sum'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.mod'
|
||||
- '**.sum'
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
go-generate-check:
|
||||
name: go-vendor-check
|
||||
container:
|
||||
image: registry.opensuse.org/devel/factory/git-workflow/containers/opensuse/bci/golang-extended:latest
|
||||
steps:
|
||||
- run: git clone --no-checkout --depth 1 ${{ gitea.server_url }}/${{ gitea.repository }} .
|
||||
- run: git fetch origin ${{ gitea.ref }}
|
||||
- run: git checkout FETCH_HEAD
|
||||
- run: go mod download
|
||||
- run: go mod vendor
|
||||
- run: go mod verify
|
||||
- run: git add -N .; git diff
|
||||
- run: go mod tidy -diff || true
|
||||
- run: |
|
||||
status=$(git status --short)
|
||||
if [[ -n "$status" ]]; then
|
||||
echo -e "$status"
|
||||
echo "Please commit the differences from running: go generate"
|
||||
false
|
||||
fi
|
||||
26
.gitea/workflows/go-vendor-push.yaml
Normal file
26
.gitea/workflows/go-vendor-push.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: go-generate-push
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
go-generate-push:
|
||||
name: go-generate-push
|
||||
container:
|
||||
image: registry.opensuse.org/devel/factory/git-workflow/containers/opensuse/bci/golang-extended:latest
|
||||
steps:
|
||||
- run: git clone --no-checkout --depth 1 ${{ gitea.server_url }}/${{ gitea.repository }} .
|
||||
- run: git fetch origin ${{ gitea.ref }}
|
||||
- run: git checkout FETCH_HEAD
|
||||
- run: go mod download
|
||||
- run: go mod vendor
|
||||
- run: go mod verify
|
||||
- run: |
|
||||
host=${{ gitea.server_url }}
|
||||
host=${host#https://}
|
||||
echo $host
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.GITEA_TOKEN }}@$host/${{ gitea.repository }}"
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "gitea_noreply@opensuse.org"
|
||||
- run: 'git status --short; git status --porcelain=2|grep --quiet -v . || ( git add .;git commit -m "CI run result of: go mod vendor"; git push origin HEAD:${{ gitea.ref }} )'
|
||||
- run: go mod tidy -diff || true
|
||||
- run: git log -p FETCH_HEAD...HEAD
|
||||
- run: git log --numstat FETCH_HEAD...HEAD
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,2 @@
|
||||
mock
|
||||
node_modules
|
||||
*.obscpio
|
||||
autogits-tmp.tar.zst
|
||||
*.osc
|
||||
*.conf
|
||||
|
||||
19
README.md
19
README.md
@@ -5,11 +5,15 @@ The bots that drive Git Workflow for package management
|
||||
|
||||
* devel-importer -- helper to import an OBS devel project into a Gitea organization
|
||||
* gitea-events-rabbitmq-publisher -- takes all events from a Gitea organization (webhook) and publishes it on a RabbitMQ instance
|
||||
* gitea-status-proxy -- allows bots without code owner permission to set Gitea's commit status
|
||||
* group-review -- group review proxy
|
||||
* hujson -- translates JWCC (json with commas and comments) to Standard JSON
|
||||
* obs-forward-bot -- forwards PR as OBS sr (TODO)
|
||||
* obs-staging-bot -- build bot for a PR
|
||||
* obs-status-service -- report build status of an OBS project as an SVG
|
||||
* workflow-pr -- keeps PR to _ObsPrj consistent with a PR to a package update
|
||||
* workflow-direct -- update _ObsPrj based on direct pushes and repo creations/removals from organization
|
||||
* staging-utils -- review tooling for PR
|
||||
* staging-utils -- review tooling for PR (TODO)
|
||||
- list PR
|
||||
- merge PR
|
||||
- split PR
|
||||
@@ -19,7 +23,18 @@ The bots that drive Git Workflow for package management
|
||||
Bugs
|
||||
----
|
||||
|
||||
Report bugs to issue tracker at https://src.opensuse.org/adamm/autogits
|
||||
Report bugs to issue tracker at https://src.opensuse.org/git-workflow/autogits
|
||||
|
||||
|
||||
Build Status
|
||||
------------
|
||||
|
||||
Devel project build status (`main` branch):
|
||||
|
||||

|
||||
|
||||
`staging` branch build status:
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
15
_service
15
_service
@@ -1,15 +0,0 @@
|
||||
<services>
|
||||
<!-- workaround, go_modules needs a tar and obs_scm doesn't take file://. -->
|
||||
<service name="roast" mode="manual">
|
||||
<param name="target">.</param>
|
||||
<param name="reproducible">true</param>
|
||||
<param name="outfile">autogits-tmp.tar.zst</param>
|
||||
<param name="exclude">autogits-tmp.tar.zst</param>
|
||||
</service>
|
||||
<service name="go_modules" mode="manual">
|
||||
<param name="basename">./</param>
|
||||
<param name="compression">zst</param>
|
||||
<param name="vendorname">vendor</param>
|
||||
</service>
|
||||
</services>
|
||||
|
||||
243
autogits.spec
243
autogits.spec
@@ -17,15 +17,14 @@
|
||||
|
||||
|
||||
Name: autogits
|
||||
Version: 0
|
||||
Version: 1
|
||||
Release: 0
|
||||
Summary: GitWorkflow utilities
|
||||
License: GPL-2.0-or-later
|
||||
URL: https://src.opensuse.org/adamm/autogits
|
||||
Source1: vendor.tar.zst
|
||||
BuildRequires: golang-packaging
|
||||
BuildRequires: git
|
||||
BuildRequires: systemd-rpm-macros
|
||||
BuildRequires: zstd
|
||||
BuildRequires: go
|
||||
%{?systemd_ordering}
|
||||
|
||||
%description
|
||||
@@ -33,160 +32,268 @@ Git Workflow tooling and utilities enabling automated handing of OBS projects
|
||||
as git repositories
|
||||
|
||||
|
||||
%package -n gitea-events-rabbitmq-publisher
|
||||
%package devel-importer
|
||||
Summary: Imports devel projects from obs to git
|
||||
|
||||
%description -n autogits-devel-importer
|
||||
Command-line tool to import devel projects from obs to git
|
||||
|
||||
|
||||
%package doc
|
||||
Summary: Common documentation files
|
||||
BuildArch: noarch
|
||||
|
||||
%description -n autogits-doc
|
||||
Common documentation files
|
||||
|
||||
|
||||
%package gitea-events-rabbitmq-publisher
|
||||
Summary: Publishes Gitea webhook data via RabbitMQ
|
||||
|
||||
%description -n gitea-events-rabbitmq-publisher
|
||||
%description gitea-events-rabbitmq-publisher
|
||||
Listens on an HTTP socket and publishes Gitea events on a RabbitMQ instance
|
||||
with a topic
|
||||
<scope>.src.$organization.$webhook_type.[$webhook_action_type]
|
||||
|
||||
|
||||
%package -n doc
|
||||
Summary: Common documentation files
|
||||
%package gitea-status-proxy
|
||||
Summary: Proxy for setting commit status in Gitea
|
||||
|
||||
%description -n doc
|
||||
Common documentation files
|
||||
%description gitea-status-proxy
|
||||
Setting commit status requires code write access token. This proxy
|
||||
is middleware that delegates status setting without access to other APIs
|
||||
|
||||
|
||||
%package -n devel-importer
|
||||
Summary: Imports devel projects from obs to git
|
||||
|
||||
%description -n devel-importer
|
||||
Command-line tool to import devel projects from obs to git
|
||||
|
||||
|
||||
%package -n group-review
|
||||
%package group-review
|
||||
Summary: Reviews of groups defined in ProjectGit
|
||||
|
||||
%description -n group-review
|
||||
%description group-review
|
||||
Is used to handle reviews associated with groups defined in the
|
||||
ProjectGit.
|
||||
|
||||
|
||||
%package -n obs-staging-bot
|
||||
%package obs-forward-bot
|
||||
Summary: obs-forward-bot
|
||||
|
||||
%description obs-forward-bot
|
||||
|
||||
|
||||
%package obs-staging-bot
|
||||
Summary: Build a PR against a ProjectGit, if review is requested
|
||||
|
||||
%description -n obs-staging-bot
|
||||
%description obs-staging-bot
|
||||
Build a PR against a ProjectGit, if review is requested.
|
||||
|
||||
|
||||
%package -n obs-status-service
|
||||
%package obs-status-service
|
||||
Summary: Reports build status of OBS service as an easily to produce SVG
|
||||
|
||||
%description -n obs-status-service
|
||||
%description obs-status-service
|
||||
Reports build status of OBS service as an easily to produce SVG
|
||||
|
||||
|
||||
%package -n workflow-direct
|
||||
Summary: Keep ProjectGit in sync for a devel project
|
||||
%package utils
|
||||
Summary: HuJSON to JSON parser
|
||||
Provides: hujson
|
||||
Provides: /usr/bin/hujson
|
||||
|
||||
%description -n workflow-direct
|
||||
%description utils
|
||||
HuJSON to JSON parser, using stdin -> stdout pipe
|
||||
|
||||
|
||||
%package workflow-direct
|
||||
Summary: Keep ProjectGit in sync for a devel project
|
||||
Requires: openssh-clients
|
||||
Requires: git-core
|
||||
|
||||
%description workflow-direct
|
||||
Keep ProjectGit in sync with packages in the organization of a devel project
|
||||
|
||||
|
||||
%package -n workflow-pr
|
||||
%package workflow-pr
|
||||
Summary: Keeps ProjectGit PR in-sync with a PackageGit PR
|
||||
Requires: openssh-clients
|
||||
Requires: git-core
|
||||
|
||||
%description -n workflow-pr
|
||||
%description workflow-pr
|
||||
Keeps ProjectGit PR in-sync with a PackageGit PR
|
||||
|
||||
|
||||
|
||||
%prep
|
||||
cp -r /home/abuild/rpmbuild/SOURCES/* ./
|
||||
tar x --zstd -f %{SOURCE1}
|
||||
|
||||
%build
|
||||
go build \
|
||||
-C gitea-events-rabbitmq-publisher \
|
||||
-mod=vendor \
|
||||
-C devel-importer \
|
||||
-buildmode=pie
|
||||
go build \
|
||||
-C devel-importer \
|
||||
-mod=vendor \
|
||||
-C utils/hujson \
|
||||
-buildmode=pie
|
||||
go build \
|
||||
-C gitea-events-rabbitmq-publisher \
|
||||
-buildmode=pie
|
||||
go build \
|
||||
-C gitea_status_proxy \
|
||||
-buildmode=pie
|
||||
go build \
|
||||
-C group-review \
|
||||
-mod=vendor \
|
||||
-buildmode=pie
|
||||
go build \
|
||||
-C obs-forward-bot \
|
||||
-buildmode=pie
|
||||
go build \
|
||||
-C obs-staging-bot \
|
||||
-mod=vendor \
|
||||
-buildmode=pie
|
||||
go build \
|
||||
-C obs-status-service \
|
||||
-mod=vendor \
|
||||
-buildmode=pie
|
||||
#go build \
|
||||
# -C workflow-direct \
|
||||
# -mod=vendor \
|
||||
# -buildmode=pie
|
||||
#go build \
|
||||
# -C workflow-pr \
|
||||
# -mod=vendor \
|
||||
# -buildmode=pie
|
||||
go build \
|
||||
-C workflow-direct \
|
||||
-buildmode=pie
|
||||
go build \
|
||||
-C workflow-pr \
|
||||
-buildmode=pie
|
||||
|
||||
%check
|
||||
go test -C common -v
|
||||
go test -C group-review -v
|
||||
go test -C obs-staging-bot -v
|
||||
go test -C obs-status-service -v
|
||||
go test -C workflow-direct -v
|
||||
# TODO build fails
|
||||
#go test -C workflow-pr -v
|
||||
|
||||
%install
|
||||
install -D -m0755 devel-importer/devel-importer %{buildroot}%{_bindir}/devel-importer
|
||||
install -D -m0755 gitea-events-rabbitmq-publisher/gitea-events-rabbitmq-publisher %{buildroot}%{_bindir}/gitea-events-rabbitmq-publisher
|
||||
install -D -m0644 systemd/gitea-events-rabbitmq-publisher.service %{buildroot}%{_unitdir}/gitea-events-rabbitmq-publisher.service
|
||||
install -D -m0755 devel-importer/devel-importer %{buildroot}%{_bindir}/devel-importer
|
||||
install -D -m0755 gitea_status_proxy/gitea_status_proxy %{buildroot}%{_bindir}/gitea_status_proxy
|
||||
install -D -m0755 group-review/group-review %{buildroot}%{_bindir}/group-review
|
||||
install -D -m0644 systemd/group-review@.service %{buildroot}%{_unitdir}/group-review@.service
|
||||
install -D -m0755 obs-forward-bot/obs-forward-bot %{buildroot}%{_bindir}/obs-forward-bot
|
||||
install -D -m0755 obs-staging-bot/obs-staging-bot %{buildroot}%{_bindir}/obs-staging-bot
|
||||
install -D -m0644 systemd/obs-staging-bot.service %{buildroot}%{_unitdir}/obs-staging-bot.service
|
||||
install -D -m0755 obs-status-service/obs-status-service %{buildroot}%{_bindir}/obs-status-service
|
||||
#install -D -m0755 workflow-direct/workflow-direct %{buildroot}%{_bindir}/workflow-direct
|
||||
#install -D -m0755 workflow-pr/workflow-pr %{buildroot}%{_bindir}/workflow-pr
|
||||
install -D -m0644 systemd/obs-status-service.service %{buildroot}%{_unitdir}/obs-status-service.service
|
||||
install -D -m0755 workflow-direct/workflow-direct %{buildroot}%{_bindir}/workflow-direct
|
||||
install -D -m0644 systemd/workflow-direct@.service %{buildroot}%{_unitdir}/workflow-direct@.service
|
||||
install -D -m0755 workflow-pr/workflow-pr %{buildroot}%{_bindir}/workflow-pr
|
||||
install -D -m0755 utils/hujson/hujson %{buildroot}%{_bindir}/hujson
|
||||
|
||||
%pre -n gitea-events-rabbitmq-publisher
|
||||
%pre gitea-events-rabbitmq-publisher
|
||||
%service_add_pre gitea-events-rabbitmq-publisher.service
|
||||
|
||||
%post -n gitea-events-rabbitmq-publisher
|
||||
%post gitea-events-rabbitmq-publisher
|
||||
%service_add_post gitea-events-rabbitmq-publisher.service
|
||||
|
||||
%preun -n gitea-events-rabbitmq-publisher
|
||||
%preun gitea-events-rabbitmq-publisher
|
||||
%service_del_preun gitea-events-rabbitmq-publisher.service
|
||||
|
||||
%postun -n gitea-events-rabbitmq-publisher
|
||||
%postun gitea-events-rabbitmq-publisher
|
||||
%service_del_postun gitea-events-rabbitmq-publisher.service
|
||||
|
||||
%files -n gitea-events-rabbitmq-publisher
|
||||
%pre group-review
|
||||
%service_add_pre group-review@.service
|
||||
|
||||
%post group-review
|
||||
%service_add_post group-review@.service
|
||||
|
||||
%preun group-review
|
||||
%service_del_preun group-review@.service
|
||||
|
||||
%postun group-review
|
||||
%service_del_postun group-review@.service
|
||||
|
||||
%pre obs-staging-bot
|
||||
%service_add_pre obs-staging-bot.service
|
||||
|
||||
%post obs-staging-bot
|
||||
%service_add_post obs-staging-bot.service
|
||||
|
||||
%preun obs-staging-bot
|
||||
%service_del_preun obs-staging-bot.service
|
||||
|
||||
%postun obs-staging-bot
|
||||
%service_del_postun obs-staging-bot.service
|
||||
|
||||
%pre obs-status-service
|
||||
%service_add_pre obs-status-service.service
|
||||
|
||||
%post obs-status-service
|
||||
%service_add_post obs-status-service.service
|
||||
|
||||
%preun obs-status-service
|
||||
%service_del_preun obs-status-service.service
|
||||
|
||||
%postun obs-status-service
|
||||
%service_del_postun obs-status-service.service
|
||||
|
||||
%pre workflow-pr
|
||||
%service_add_pre workflow-direct@.service
|
||||
|
||||
%post workflow-pr
|
||||
%service_add_post workflow-direct@.service
|
||||
|
||||
%preun workflow-pr
|
||||
%service_del_preun workflow-direct@.service
|
||||
|
||||
%postun workflow-pr
|
||||
%service_del_postun workflow-direct@.service
|
||||
|
||||
%files devel-importer
|
||||
%license COPYING
|
||||
%doc devel-importer/README.md
|
||||
%{_bindir}/devel-importer
|
||||
|
||||
%files doc
|
||||
%license COPYING
|
||||
%doc doc/README.md
|
||||
%doc doc/workflows.md
|
||||
|
||||
%files gitea-events-rabbitmq-publisher
|
||||
%license COPYING
|
||||
%doc gitea-events-rabbitmq-publisher/README.md
|
||||
%{_bindir}/gitea-events-rabbitmq-publisher
|
||||
%{_unitdir}/gitea-events-rabbitmq-publisher.service
|
||||
|
||||
%files -n doc
|
||||
%files gitea-status-proxy
|
||||
%license COPYING
|
||||
%doc doc/README.md
|
||||
%doc doc/workflows.md
|
||||
%{_bindir}/gitea_status_proxy
|
||||
|
||||
%files -n devel-importer
|
||||
%license COPYING
|
||||
%doc devel-importer/README.md
|
||||
%{_bindir}/devel-importer
|
||||
|
||||
%files -n group-review
|
||||
%files group-review
|
||||
%license COPYING
|
||||
%doc group-review/README.md
|
||||
%{_bindir}/group-review
|
||||
%{_unitdir}/group-review@.service
|
||||
|
||||
%files -n obs-staging-bot
|
||||
%files obs-forward-bot
|
||||
%license COPYING
|
||||
%{_bindir}/obs-forward-bot
|
||||
|
||||
%files obs-staging-bot
|
||||
%license COPYING
|
||||
%doc obs-staging-bot/README.md
|
||||
%{_bindir}/obs-staging-bot
|
||||
%{_unitdir}/obs-staging-bot.service
|
||||
|
||||
%files -n obs-status-service
|
||||
%files obs-status-service
|
||||
%license COPYING
|
||||
%doc obs-status-service/README.md
|
||||
%{_bindir}/obs-status-service
|
||||
%{_unitdir}/obs-status-service.service
|
||||
|
||||
%files -n workflow-direct
|
||||
%files utils
|
||||
%license COPYING
|
||||
%{_bindir}/hujson
|
||||
|
||||
%files workflow-direct
|
||||
%license COPYING
|
||||
%doc workflow-direct/README.md
|
||||
#%{_bindir}/workflow-direct
|
||||
%{_bindir}/workflow-direct
|
||||
%{_unitdir}/workflow-direct@.service
|
||||
|
||||
%files -n workflow-pr
|
||||
%files workflow-pr
|
||||
%license COPYING
|
||||
%doc workflow-pr/README.md
|
||||
#%{_bindir}/workflow-pr
|
||||
%{_bindir}/workflow-pr
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const PrPattern = "PR: %s/%s#%d"
|
||||
const PrPattern = "PR: %s/%s!%d"
|
||||
|
||||
type BasicPR struct {
|
||||
Org, Repo string
|
||||
@@ -36,10 +36,14 @@ func parsePrLine(line string) (BasicPR, error) {
|
||||
return ret, errors.New("missing / separator")
|
||||
}
|
||||
|
||||
repo := strings.SplitN(org[1], "#", 2)
|
||||
repo := strings.SplitN(org[1], "!", 2)
|
||||
ret.Repo = repo[0]
|
||||
if len(repo) != 2 {
|
||||
return ret, errors.New("Missing # separator")
|
||||
repo = strings.SplitN(org[1], "#", 2)
|
||||
ret.Repo = repo[0]
|
||||
}
|
||||
if len(repo) != 2 {
|
||||
return ret, errors.New("Missing ! or # separator")
|
||||
}
|
||||
|
||||
// Gitea requires that each org and repo be [A-Za-z0-9_-]+
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestAssociatedPRScanner(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"Multiple PRs",
|
||||
"Some header of the issue\n\nFollowed by some description\nPR: test/foo#4\n\nPR: test/goo#5\n",
|
||||
"Some header of the issue\n\nFollowed by some description\nPR: test/foo#4\n\nPR: test/goo!5\n",
|
||||
[]common.BasicPR{
|
||||
{Org: "test", Repo: "foo", Num: 4},
|
||||
{Org: "test", Repo: "goo", Num: 5},
|
||||
@@ -107,7 +107,7 @@ func TestAppendingPRsToDescription(t *testing.T) {
|
||||
[]common.BasicPR{
|
||||
{Org: "a", Repo: "b", Num: 100},
|
||||
},
|
||||
"something\n\nPR: a/b#100",
|
||||
"something\n\nPR: a/b!100",
|
||||
},
|
||||
{
|
||||
"Append multiple PR to end of description",
|
||||
@@ -119,7 +119,7 @@ func TestAppendingPRsToDescription(t *testing.T) {
|
||||
{Org: "b", Repo: "b", Num: 100},
|
||||
{Org: "c", Repo: "b", Num: 100},
|
||||
},
|
||||
"something\n\nPR: a1/b#100\nPR: a1/c#100\nPR: a1/c#101\nPR: b/b#100\nPR: c/b#100",
|
||||
"something\n\nPR: a1/b!100\nPR: a1/c!100\nPR: a1/c!101\nPR: b/b!100\nPR: c/b!100",
|
||||
},
|
||||
{
|
||||
"Append multiple sorted PR to end of description and remove dups",
|
||||
@@ -133,7 +133,7 @@ func TestAppendingPRsToDescription(t *testing.T) {
|
||||
{Org: "a1", Repo: "c", Num: 101},
|
||||
{Org: "a1", Repo: "b", Num: 100},
|
||||
},
|
||||
"something\n\nPR: a1/b#100\nPR: a1/c#100\nPR: a1/c#101\nPR: b/b#100\nPR: c/b#100",
|
||||
"something\n\nPR: a1/b!100\nPR: a1/c!100\nPR: a1/c!101\nPR: b/b!100\nPR: c/b!100",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
@@ -35,6 +36,9 @@ import (
|
||||
const (
|
||||
ProjectConfigFile = "workflow.config"
|
||||
StagingConfigFile = "staging.config"
|
||||
|
||||
Permission_ForceMerge = "force-merge"
|
||||
Permission_Group = "release-engineering"
|
||||
)
|
||||
|
||||
type ConfigFile struct {
|
||||
@@ -43,23 +47,49 @@ type ConfigFile struct {
|
||||
|
||||
type ReviewGroup struct {
|
||||
Name string
|
||||
Silent bool // will not request reviews from group members
|
||||
Reviewers []string
|
||||
}
|
||||
|
||||
type QAConfig struct {
|
||||
Name string
|
||||
Origin string
|
||||
BuildDisableRepos []string // which repos to build disable in the new project
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Permission string
|
||||
Members []string
|
||||
}
|
||||
|
||||
const (
|
||||
Label_StagingAuto = "staging/Auto"
|
||||
Label_ReviewPending = "review/Pending"
|
||||
Label_ReviewDone = "review/Done"
|
||||
)
|
||||
|
||||
func LabelKey(tag_value string) string {
|
||||
// capitalize first letter and remove /
|
||||
if len(tag_value) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(tag_value[0:1]) + strings.ReplaceAll(tag_value[1:], "/", "")
|
||||
}
|
||||
|
||||
type AutogitConfig struct {
|
||||
Workflows []string // [pr, direct, test]
|
||||
Organization string
|
||||
GitProjectName string // Organization/GitProjectName.git is PrjGit
|
||||
Branch string // branch name of PkgGit that aligns with PrjGit submodules
|
||||
Reviewers []string // only used by `pr` workflow
|
||||
ReviewGroups []ReviewGroup
|
||||
GitProjectName string // Organization/GitProjectName.git is PrjGit
|
||||
Branch string // branch name of PkgGit that aligns with PrjGit submodules
|
||||
Reviewers []string // only used by `pr` workflow
|
||||
Permissions []*Permissions // only used by `pr` workflow
|
||||
ReviewGroups []*ReviewGroup
|
||||
Committers []string // group in addition to Reviewers and Maintainers that can order the bot around, mostly as helper for factory-maintainers
|
||||
Subdirs []string // list of directories to sort submodules into. Needed b/c _manifest cannot list non-existent directories
|
||||
|
||||
Labels map[string]string // list of tags, if not default, to apply
|
||||
|
||||
NoProjectGitPR bool // do not automatically create project git PRs, just assign reviewers and assume somethign else creates the ProjectGit PR
|
||||
ManualMergeOnly bool // only merge with "Merge OK" comment by Project Maintainers and/or Package Maintainers and/or reviewers
|
||||
ManualMergeProject bool // require merge of ProjectGit PRs with "Merge OK" by ProjectMaintainers and/or reviewers
|
||||
}
|
||||
@@ -175,6 +205,8 @@ func (configs AutogitConfigs) GetPrjGitConfig(org, repo, branch string) *Autogit
|
||||
if c.GitProjectName == prjgit {
|
||||
return c
|
||||
}
|
||||
}
|
||||
for _, c := range configs {
|
||||
if c.Organization == org && c.Branch == branch {
|
||||
return c
|
||||
}
|
||||
@@ -183,6 +215,27 @@ func (configs AutogitConfigs) GetPrjGitConfig(org, repo, branch string) *Autogit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config *AutogitConfig) HasPermission(user, permission string) bool {
|
||||
if config == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, p := range config.Permissions {
|
||||
if p.Permission == permission {
|
||||
if slices.Contains(p.Members, user) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, m := range p.Members {
|
||||
if members, err := config.GetReviewGroupMembers(m); err == nil && slices.Contains(members, user) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (config *AutogitConfig) GetReviewGroupMembers(reviewer string) ([]string, error) {
|
||||
for _, g := range config.ReviewGroups {
|
||||
if g.Name == reviewer {
|
||||
@@ -193,10 +246,19 @@ func (config *AutogitConfig) GetReviewGroupMembers(reviewer string) ([]string, e
|
||||
return nil, errors.New("User " + reviewer + " not found as group reviewer for " + config.GitProjectName)
|
||||
}
|
||||
|
||||
func (config *AutogitConfig) GetReviewGroup(reviewer string) (*ReviewGroup, error) {
|
||||
for _, g := range config.ReviewGroups {
|
||||
if g.Name == reviewer {
|
||||
return g, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("User " + reviewer + " not found as group reviewer for " + config.GitProjectName)
|
||||
}
|
||||
|
||||
func (config *AutogitConfig) GetPrjGit() (string, string, string) {
|
||||
org := config.Organization
|
||||
repo := DefaultGitPrj
|
||||
branch := "master"
|
||||
branch := ""
|
||||
|
||||
a := strings.Split(config.GitProjectName, "/")
|
||||
if len(a[0]) > 0 {
|
||||
@@ -220,6 +282,9 @@ func (config *AutogitConfig) GetPrjGit() (string, string, string) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(branch) == 0 {
|
||||
panic("branch for project is undefined. Should not happend." + org + "/" + repo)
|
||||
}
|
||||
return org, repo, branch
|
||||
}
|
||||
|
||||
@@ -227,6 +292,14 @@ func (config *AutogitConfig) GetRemoteBranch() string {
|
||||
return "origin_" + config.Branch
|
||||
}
|
||||
|
||||
func (config *AutogitConfig) Label(label string) string {
|
||||
if t, found := config.Labels[LabelKey(label)]; found {
|
||||
return t
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
type StagingConfig struct {
|
||||
ObsProject string
|
||||
RebuildAll bool
|
||||
@@ -239,6 +312,9 @@ type StagingConfig struct {
|
||||
|
||||
func ParseStagingConfig(data []byte) (*StagingConfig, error) {
|
||||
var staging StagingConfig
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("non-existent config file.")
|
||||
}
|
||||
data, err := hujson.Standardize(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,6 +10,67 @@ import (
|
||||
mock_common "src.opensuse.org/autogits/common/mock"
|
||||
)
|
||||
|
||||
func TestLabelKey(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "",
|
||||
"foo": "Foo",
|
||||
"foo/bar": "Foobar",
|
||||
"foo/Bar": "FooBar",
|
||||
}
|
||||
|
||||
for k, v := range tests {
|
||||
if c := common.LabelKey(k); c != v {
|
||||
t.Error("expected", v, "got", c, "input", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLabelParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
label_value string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
json: "{}",
|
||||
label_value: "path/String",
|
||||
},
|
||||
{
|
||||
name: "defined",
|
||||
json: `{"Labels": {"foo": "bar", "PathString": "moo/Label"}}`,
|
||||
label_value: "moo/Label",
|
||||
},
|
||||
{
|
||||
name: "undefined",
|
||||
json: `{"Labels": {"foo": "bar", "NotPathString": "moo/Label"}}`,
|
||||
label_value: "path/String",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repo := models.Repository{
|
||||
DefaultBranch: "master",
|
||||
}
|
||||
|
||||
ctl := gomock.NewController(t)
|
||||
gitea := mock_common.NewMockGiteaFileContentAndRepoFetcher(ctl)
|
||||
gitea.EXPECT().GetRepositoryFileContent("foo", "bar", "", "workflow.config").Return([]byte(test.json), "abc", nil)
|
||||
gitea.EXPECT().GetRepository("foo", "bar").Return(&repo, nil)
|
||||
|
||||
config, err := common.ReadWorkflowConfig(gitea, "foo/bar")
|
||||
if err != nil || config == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if l := config.Label("path/String"); l != test.label_value {
|
||||
t.Error("Expecting", test.label_value, "got", l)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectConfigMatcher(t *testing.T) {
|
||||
configs := common.AutogitConfigs{
|
||||
{
|
||||
@@ -21,6 +82,15 @@ func TestProjectConfigMatcher(t *testing.T) {
|
||||
Branch: "main",
|
||||
GitProjectName: "test/prjgit#main",
|
||||
},
|
||||
{
|
||||
Organization: "test",
|
||||
Branch: "main",
|
||||
GitProjectName: "test/bar#never_match",
|
||||
},
|
||||
{
|
||||
Organization: "test",
|
||||
GitProjectName: "test/bar#main",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -50,6 +120,20 @@ func TestProjectConfigMatcher(t *testing.T) {
|
||||
branch: "main",
|
||||
config: 1,
|
||||
},
|
||||
{
|
||||
name: "prjgit only match",
|
||||
org: "test",
|
||||
repo: "bar",
|
||||
branch: "main",
|
||||
config: 3,
|
||||
},
|
||||
{
|
||||
name: "non-default branch match",
|
||||
org: "test",
|
||||
repo: "bar",
|
||||
branch: "something_main",
|
||||
config: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -105,10 +189,15 @@ func TestConfigWorkflowParser(t *testing.T) {
|
||||
if config.ManualMergeOnly != false {
|
||||
t.Fatal("This should be false")
|
||||
}
|
||||
|
||||
if config.Label("foobar") != "foobar" {
|
||||
t.Fatal("undefined label should return default value")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: should test ReadWorkflowConfig as it will always set prjgit completely
|
||||
func TestProjectGitParser(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -119,20 +208,21 @@ func TestProjectGitParser(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "repo only",
|
||||
prjgit: "repo.git",
|
||||
prjgit: "repo.git#master",
|
||||
org: "org",
|
||||
branch: "br",
|
||||
res: [3]string{"org", "repo.git", "master"},
|
||||
},
|
||||
{
|
||||
name: "default",
|
||||
org: "org",
|
||||
res: [3]string{"org", common.DefaultGitPrj, "master"},
|
||||
name: "default",
|
||||
org: "org",
|
||||
prjgit: "org/_ObsPrj#master",
|
||||
res: [3]string{"org", common.DefaultGitPrj, "master"},
|
||||
},
|
||||
{
|
||||
name: "repo with branch",
|
||||
org: "org2",
|
||||
prjgit: "repo.git#somebranch",
|
||||
prjgit: "org2/repo.git#somebranch",
|
||||
res: [3]string{"org2", "repo.git", "somebranch"},
|
||||
},
|
||||
{
|
||||
@@ -149,25 +239,25 @@ func TestProjectGitParser(t *testing.T) {
|
||||
{
|
||||
name: "repo org and empty branch",
|
||||
org: "org3",
|
||||
prjgit: "oorg/foo.bar#",
|
||||
prjgit: "oorg/foo.bar#master",
|
||||
res: [3]string{"oorg", "foo.bar", "master"},
|
||||
},
|
||||
{
|
||||
name: "only branch defined",
|
||||
org: "org3",
|
||||
prjgit: "#mybranch",
|
||||
prjgit: "org3/_ObsPrj#mybranch",
|
||||
res: [3]string{"org3", "_ObsPrj", "mybranch"},
|
||||
},
|
||||
{
|
||||
name: "only org and branch defined",
|
||||
org: "org3",
|
||||
prjgit: "org1/#mybranch",
|
||||
prjgit: "org1/_ObsPrj#mybranch",
|
||||
res: [3]string{"org1", "_ObsPrj", "mybranch"},
|
||||
},
|
||||
{
|
||||
name: "empty org and repo",
|
||||
org: "org3",
|
||||
prjgit: "/repo#",
|
||||
prjgit: "org3/repo#master",
|
||||
res: [3]string{"org3", "repo", "master"},
|
||||
},
|
||||
}
|
||||
@@ -188,3 +278,67 @@ func TestProjectGitParser(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPermissions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permission string
|
||||
user string
|
||||
config *common.AutogitConfig
|
||||
result bool
|
||||
}{
|
||||
{
|
||||
name: "NoPermissions",
|
||||
permission: common.Permission_ForceMerge,
|
||||
},
|
||||
{
|
||||
name: "NoPermissions",
|
||||
permission: common.Permission_Group,
|
||||
},
|
||||
{
|
||||
name: "Regular permission ForcePush",
|
||||
permission: common.Permission_ForceMerge,
|
||||
result: true,
|
||||
user: "user",
|
||||
config: &common.AutogitConfig{
|
||||
Permissions: []*common.Permissions{
|
||||
&common.Permissions{
|
||||
Permission: common.Permission_ForceMerge,
|
||||
Members: []string{"user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "User is part of a group",
|
||||
permission: common.Permission_ForceMerge,
|
||||
result: true,
|
||||
user: "user",
|
||||
config: &common.AutogitConfig{
|
||||
Permissions: []*common.Permissions{
|
||||
&common.Permissions{
|
||||
Permission: common.Permission_ForceMerge,
|
||||
Members: []string{"group"},
|
||||
},
|
||||
},
|
||||
ReviewGroups: []*common.ReviewGroup{
|
||||
&common.ReviewGroup{
|
||||
Name: "group",
|
||||
Reviewers: []string{"some", "members", "including", "user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if r := test.config.HasPermission(test.user, test.permission); r != test.result {
|
||||
t.Error("Expecting", test.result, "but got opposite")
|
||||
}
|
||||
if r := test.config.HasPermission(test.user+test.user, test.permission); r {
|
||||
t.Error("Expecting false for fake user, but got opposite")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1731,3 +1731,246 @@ const requestedReviewJSON = `{
|
||||
"commit_id": "",
|
||||
"review": null
|
||||
}`
|
||||
|
||||
const requestStatusJSON=`{
|
||||
"commit": {
|
||||
"id": "e637d86cbbdd438edbf60148e28f9d75a74d51b27b01f75610f247cd18394c8e",
|
||||
"message": "Update nodejs-common.changes\n",
|
||||
"url": "https://src.opensuse.org/autogits/nodejs-common/commit/e637d86cbbdd438edbf60148e28f9d75a74d51b27b01f75610f247cd18394c8e",
|
||||
"author": {
|
||||
"name": "Adam Majer",
|
||||
"email": "adamm@noreply.src.opensuse.org",
|
||||
"username": "adamm"
|
||||
},
|
||||
"committer": {
|
||||
"name": "Adam Majer",
|
||||
"email": "adamm@noreply.src.opensuse.org",
|
||||
"username": "adamm"
|
||||
},
|
||||
"verification": null,
|
||||
"timestamp": "2025-09-16T12:41:02+02:00",
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"modified": [
|
||||
"nodejs-common.changes"
|
||||
]
|
||||
},
|
||||
"context": "test",
|
||||
"created_at": "2025-09-16T10:50:32Z",
|
||||
"description": "",
|
||||
"id": 21663,
|
||||
"repository": {
|
||||
"id": 90520,
|
||||
"owner": {
|
||||
"id": 983,
|
||||
"login": "autogits",
|
||||
"login_name": "",
|
||||
"source_id": 0,
|
||||
"full_name": "",
|
||||
"email": "",
|
||||
"avatar_url": "https://src.opensuse.org/avatars/80a61ef3a14c3c22f0b8b1885d1a75d4",
|
||||
"html_url": "https://src.opensuse.org/autogits",
|
||||
"language": "",
|
||||
"is_admin": false,
|
||||
"last_login": "0001-01-01T00:00:00Z",
|
||||
"created": "2024-06-20T09:46:37+02:00",
|
||||
"restricted": false,
|
||||
"active": false,
|
||||
"prohibit_login": false,
|
||||
"location": "",
|
||||
"website": "",
|
||||
"description": "",
|
||||
"visibility": "public",
|
||||
"followers_count": 0,
|
||||
"following_count": 0,
|
||||
"starred_repos_count": 0,
|
||||
"username": "autogits"
|
||||
},
|
||||
"name": "nodejs-common",
|
||||
"full_name": "autogits/nodejs-common",
|
||||
"description": "",
|
||||
"empty": false,
|
||||
"private": false,
|
||||
"fork": true,
|
||||
"template": false,
|
||||
"parent": {
|
||||
"id": 62649,
|
||||
"owner": {
|
||||
"id": 64,
|
||||
"login": "pool",
|
||||
"login_name": "",
|
||||
"source_id": 0,
|
||||
"full_name": "",
|
||||
"email": "",
|
||||
"avatar_url": "https://src.opensuse.org/avatars/b10a8c0bede9eb4ea771b04db3149f28",
|
||||
"html_url": "https://src.opensuse.org/pool",
|
||||
"language": "",
|
||||
"is_admin": false,
|
||||
"last_login": "0001-01-01T00:00:00Z",
|
||||
"created": "2023-03-01T14:41:17+01:00",
|
||||
"restricted": false,
|
||||
"active": false,
|
||||
"prohibit_login": false,
|
||||
"location": "",
|
||||
"website": "",
|
||||
"description": "",
|
||||
"visibility": "public",
|
||||
"followers_count": 2,
|
||||
"following_count": 0,
|
||||
"starred_repos_count": 0,
|
||||
"username": "pool"
|
||||
},
|
||||
"name": "nodejs-common",
|
||||
"full_name": "pool/nodejs-common",
|
||||
"description": "",
|
||||
"empty": false,
|
||||
"private": false,
|
||||
"fork": false,
|
||||
"template": false,
|
||||
"mirror": false,
|
||||
"size": 134,
|
||||
"language": "",
|
||||
"languages_url": "https://src.opensuse.org/api/v1/repos/pool/nodejs-common/languages",
|
||||
"html_url": "https://src.opensuse.org/pool/nodejs-common",
|
||||
"url": "https://src.opensuse.org/api/v1/repos/pool/nodejs-common",
|
||||
"link": "",
|
||||
"ssh_url": "gitea@src.opensuse.org:pool/nodejs-common.git",
|
||||
"clone_url": "https://src.opensuse.org/pool/nodejs-common.git",
|
||||
"original_url": "",
|
||||
"website": "",
|
||||
"stars_count": 0,
|
||||
"forks_count": 3,
|
||||
"watchers_count": 12,
|
||||
"open_issues_count": 0,
|
||||
"open_pr_counter": 0,
|
||||
"release_counter": 0,
|
||||
"default_branch": "factory",
|
||||
"archived": false,
|
||||
"created_at": "2024-06-17T17:08:45+02:00",
|
||||
"updated_at": "2025-08-21T21:58:31+02:00",
|
||||
"archived_at": "1970-01-01T01:00:00+01:00",
|
||||
"permissions": {
|
||||
"admin": true,
|
||||
"push": true,
|
||||
"pull": true
|
||||
},
|
||||
"has_issues": true,
|
||||
"internal_tracker": {
|
||||
"enable_time_tracker": false,
|
||||
"allow_only_contributors_to_track_time": true,
|
||||
"enable_issue_dependencies": true
|
||||
},
|
||||
"has_wiki": false,
|
||||
"has_pull_requests": true,
|
||||
"has_projects": false,
|
||||
"projects_mode": "all",
|
||||
"has_releases": false,
|
||||
"has_packages": false,
|
||||
"has_actions": false,
|
||||
"ignore_whitespace_conflicts": false,
|
||||
"allow_merge_commits": true,
|
||||
"allow_rebase": true,
|
||||
"allow_rebase_explicit": true,
|
||||
"allow_squash_merge": true,
|
||||
"allow_fast_forward_only_merge": true,
|
||||
"allow_rebase_update": true,
|
||||
"allow_manual_merge": true,
|
||||
"autodetect_manual_merge": true,
|
||||
"default_delete_branch_after_merge": false,
|
||||
"default_merge_style": "merge",
|
||||
"default_allow_maintainer_edit": false,
|
||||
"avatar_url": "",
|
||||
"internal": false,
|
||||
"mirror_interval": "",
|
||||
"object_format_name": "sha256",
|
||||
"mirror_updated": "0001-01-01T00:00:00Z",
|
||||
"topics": [],
|
||||
"licenses": []
|
||||
},
|
||||
"mirror": false,
|
||||
"size": 143,
|
||||
"language": "",
|
||||
"languages_url": "https://src.opensuse.org/api/v1/repos/autogits/nodejs-common/languages",
|
||||
"html_url": "https://src.opensuse.org/autogits/nodejs-common",
|
||||
"url": "https://src.opensuse.org/api/v1/repos/autogits/nodejs-common",
|
||||
"link": "",
|
||||
"ssh_url": "gitea@src.opensuse.org:autogits/nodejs-common.git",
|
||||
"clone_url": "https://src.opensuse.org/autogits/nodejs-common.git",
|
||||
"original_url": "",
|
||||
"website": "",
|
||||
"stars_count": 0,
|
||||
"forks_count": 1,
|
||||
"watchers_count": 4,
|
||||
"open_issues_count": 0,
|
||||
"open_pr_counter": 1,
|
||||
"release_counter": 0,
|
||||
"default_branch": "factory",
|
||||
"archived": false,
|
||||
"created_at": "2024-07-01T13:29:03+02:00",
|
||||
"updated_at": "2025-09-16T12:41:03+02:00",
|
||||
"archived_at": "1970-01-01T01:00:00+01:00",
|
||||
"permissions": {
|
||||
"admin": true,
|
||||
"push": true,
|
||||
"pull": true
|
||||
},
|
||||
"has_issues": false,
|
||||
"has_wiki": false,
|
||||
"has_pull_requests": true,
|
||||
"has_projects": false,
|
||||
"projects_mode": "all",
|
||||
"has_releases": false,
|
||||
"has_packages": false,
|
||||
"has_actions": false,
|
||||
"ignore_whitespace_conflicts": false,
|
||||
"allow_merge_commits": true,
|
||||
"allow_rebase": true,
|
||||
"allow_rebase_explicit": true,
|
||||
"allow_squash_merge": true,
|
||||
"allow_fast_forward_only_merge": true,
|
||||
"allow_rebase_update": true,
|
||||
"allow_manual_merge": true,
|
||||
"autodetect_manual_merge": true,
|
||||
"default_delete_branch_after_merge": false,
|
||||
"default_merge_style": "merge",
|
||||
"default_allow_maintainer_edit": false,
|
||||
"avatar_url": "",
|
||||
"internal": false,
|
||||
"mirror_interval": "",
|
||||
"object_format_name": "sha256",
|
||||
"mirror_updated": "0001-01-01T00:00:00Z",
|
||||
"topics": [],
|
||||
"licenses": [
|
||||
"MIT"
|
||||
]
|
||||
},
|
||||
"sender": {
|
||||
"id": 129,
|
||||
"login": "adamm",
|
||||
"login_name": "",
|
||||
"source_id": 0,
|
||||
"full_name": "Adam Majer",
|
||||
"email": "adamm@noreply.src.opensuse.org",
|
||||
"avatar_url": "https://src.opensuse.org/avatars/3e8917bfbf04293f7c20c28cacd83dae2ba9b78a6c6a9a1bedf14c683d8a3763",
|
||||
"html_url": "https://src.opensuse.org/adamm",
|
||||
"language": "",
|
||||
"is_admin": false,
|
||||
"last_login": "0001-01-01T00:00:00Z",
|
||||
"created": "2023-07-21T16:43:48+02:00",
|
||||
"restricted": false,
|
||||
"active": false,
|
||||
"prohibit_login": false,
|
||||
"location": "",
|
||||
"website": "",
|
||||
"description": "",
|
||||
"visibility": "public",
|
||||
"followers_count": 1,
|
||||
"following_count": 0,
|
||||
"starred_repos_count": 0,
|
||||
"username": "adamm"
|
||||
},
|
||||
"sha": "e637d86cbbdd438edbf60148e28f9d75a74d51b27b01f75610f247cd18394c8e",
|
||||
"state": "pending",
|
||||
"target_url": "https://src.opensuse.org/",
|
||||
"updated_at": "2025-09-16T10:50:32Z"
|
||||
}`
|
||||
|
||||
@@ -40,6 +40,10 @@ type GitSubmoduleLister interface {
|
||||
GitSubmoduleCommitId(cwd, packageName, commitId string) (subCommitId string, valid bool)
|
||||
}
|
||||
|
||||
type GitDirectoryLister interface {
|
||||
GitDirectoryList(gitPath, commitId string) (dirlist map[string]string, err error)
|
||||
}
|
||||
|
||||
type GitStatusLister interface {
|
||||
GitStatus(cwd string) ([]GitStatusData, error)
|
||||
}
|
||||
@@ -61,12 +65,14 @@ type Git interface {
|
||||
io.Closer
|
||||
|
||||
GitSubmoduleLister
|
||||
GitDirectoryLister
|
||||
GitStatusLister
|
||||
|
||||
GitExecWithOutputOrPanic(cwd string, params ...string) string
|
||||
GitExecOrPanic(cwd string, params ...string)
|
||||
GitExec(cwd string, params ...string) error
|
||||
GitExecWithOutput(cwd string, params ...string) (string, error)
|
||||
GitExecQuietOrPanic(cwd string, params ...string)
|
||||
|
||||
GitDiffLister
|
||||
}
|
||||
@@ -76,7 +82,8 @@ type GitHandlerImpl struct {
|
||||
GitCommiter string
|
||||
GitEmail string
|
||||
|
||||
lock *sync.Mutex
|
||||
lock *sync.Mutex
|
||||
quiet bool
|
||||
}
|
||||
|
||||
func (s *GitHandlerImpl) GetPath() string {
|
||||
@@ -211,7 +218,7 @@ func (e *GitHandlerImpl) GitClone(repo, branch, remoteUrl string) (string, error
|
||||
return "", fmt.Errorf("Cannot parse remote URL: %w", err)
|
||||
}
|
||||
remoteBranch := "HEAD"
|
||||
if len(branch) == 0 && remoteUrlComp != nil {
|
||||
if len(branch) == 0 && remoteUrlComp != nil && remoteUrlComp.Commit != "HEAD" {
|
||||
branch = remoteUrlComp.Commit
|
||||
remoteBranch = branch
|
||||
} else if len(branch) > 0 {
|
||||
@@ -240,46 +247,51 @@ func (e *GitHandlerImpl) GitClone(repo, branch, remoteUrl string) (string, error
|
||||
|
||||
// check if we have submodule to deinit
|
||||
if list, _ := e.GitSubmoduleList(repo, "HEAD"); len(list) > 0 {
|
||||
e.GitExecOrPanic(repo, "submodule", "deinit", "--all", "--force")
|
||||
e.GitExecQuietOrPanic(repo, "submodule", "deinit", "--all", "--force")
|
||||
}
|
||||
|
||||
e.GitExecOrPanic(repo, "fetch", "--prune", remoteName, remoteBranch)
|
||||
}
|
||||
/*
|
||||
refsBytes, err := os.ReadFile(path.Join(e.GitPath, repo, ".git/refs/remotes", remoteName, "HEAD"))
|
||||
if err != nil {
|
||||
LogError("Cannot read HEAD of remote", remoteName)
|
||||
return remoteName, fmt.Errorf("Cannot read HEAD of remote %s", remoteName)
|
||||
}
|
||||
|
||||
refsBytes, err := os.ReadFile(path.Join(e.GitPath, repo, ".git/refs/remotes", remoteName, "HEAD"))
|
||||
if err != nil {
|
||||
LogError("Cannot read HEAD of remote", remoteName)
|
||||
return remoteName, fmt.Errorf("Cannot read HEAD of remote %s", remoteName)
|
||||
}
|
||||
|
||||
refs := string(refsBytes)
|
||||
if refs[0:5] != "ref: " {
|
||||
LogError("Unexpected format of remote HEAD ref:", refs)
|
||||
return remoteName, fmt.Errorf("Unexpected format of remote HEAD ref: %s", refs)
|
||||
}
|
||||
|
||||
if len(branch) == 0 || branch == "HEAD" {
|
||||
remoteRef = strings.TrimSpace(refs[5:])
|
||||
branch = remoteRef[strings.LastIndex(remoteRef, "/")+1:]
|
||||
LogDebug("remoteRef", remoteRef)
|
||||
LogDebug("branch", branch)
|
||||
}
|
||||
refs := string(refsBytes)
|
||||
if refs[0:5] != "ref: " {
|
||||
LogError("Unexpected format of remote HEAD ref:", refs)
|
||||
return remoteName, fmt.Errorf("Unexpected format of remote HEAD ref: %s", refs)
|
||||
}
|
||||
|
||||
if len(branch) == 0 || branch == "HEAD" {
|
||||
remoteRef = strings.TrimSpace(refs[5:])
|
||||
branch = remoteRef[strings.LastIndex(remoteRef, "/")+1:]
|
||||
LogDebug("remoteRef", remoteRef)
|
||||
LogDebug("branch", branch)
|
||||
}
|
||||
*/
|
||||
args := []string{"fetch", "--prune", remoteName, branch}
|
||||
if strings.TrimSpace(e.GitExecWithOutputOrPanic(repo, "rev-parse", "--is-shallow-repository")) == "true" {
|
||||
args = slices.Insert(args, 1, "--unshallow")
|
||||
}
|
||||
e.GitExecOrPanic(repo, args...)
|
||||
return remoteName, e.GitExec(repo, "checkout", "--track", "-B", branch, remoteRef)
|
||||
return remoteName, e.GitExec(repo, "checkout", "-f", "--track", "-B", branch, remoteRef)
|
||||
}
|
||||
|
||||
func (e *GitHandlerImpl) GitBranchHead(gitDir, branchName string) (string, error) {
|
||||
id, err := e.GitExecWithOutput(gitDir, "show-ref", "--hash", "--verify", "refs/heads/"+branchName)
|
||||
id, err := e.GitExecWithOutput(gitDir, "show-ref", "--heads", "--hash", branchName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Can't find default branch: %s", branchName)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(id), nil
|
||||
id = strings.TrimSpace(SplitLines(id)[0])
|
||||
if len(id) < 10 {
|
||||
return "", fmt.Errorf("Can't find branch: %s", branchName)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (e *GitHandlerImpl) GitRemoteHead(gitDir, remote, branchName string) (string, error) {
|
||||
@@ -338,6 +350,10 @@ var ExtraGitParams []string
|
||||
|
||||
func (e *GitHandlerImpl) GitExecWithOutput(cwd string, params ...string) (string, error) {
|
||||
cmd := exec.Command("/usr/bin/git", params...)
|
||||
var identityFile string
|
||||
if i := os.Getenv("AUTOGITS_IDENTITY_FILE"); len(i) > 0 {
|
||||
identityFile = " -i " + i
|
||||
}
|
||||
cmd.Env = []string{
|
||||
"GIT_CEILING_DIRECTORIES=" + e.GitPath,
|
||||
"GIT_CONFIG_GLOBAL=/dev/null",
|
||||
@@ -345,7 +361,8 @@ func (e *GitHandlerImpl) GitExecWithOutput(cwd string, params ...string) (string
|
||||
"GIT_COMMITTER_NAME=" + e.GitCommiter,
|
||||
"EMAIL=not@exist@src.opensuse.org",
|
||||
"GIT_LFS_SKIP_SMUDGE=1",
|
||||
"GIT_SSH_COMMAND=/usr/bin/ssh -o StrictHostKeyChecking=yes",
|
||||
"GIT_LFS_SKIP_PUSH=1",
|
||||
"GIT_SSH_COMMAND=/usr/bin/ssh -o StrictHostKeyChecking=yes" + identityFile,
|
||||
}
|
||||
if len(ExtraGitParams) > 0 {
|
||||
cmd.Env = append(cmd.Env, ExtraGitParams...)
|
||||
@@ -355,7 +372,9 @@ func (e *GitHandlerImpl) GitExecWithOutput(cwd string, params ...string) (string
|
||||
|
||||
LogDebug("git execute @", cwd, ":", cmd.Args)
|
||||
out, err := cmd.CombinedOutput()
|
||||
LogDebug(string(out))
|
||||
if !e.quiet {
|
||||
LogDebug(string(out))
|
||||
}
|
||||
if err != nil {
|
||||
LogError("git", cmd.Args, " error:", err)
|
||||
return "", fmt.Errorf("error executing: git %#v \n%s\n err: %w", cmd.Args, out, err)
|
||||
@@ -364,6 +383,13 @@ func (e *GitHandlerImpl) GitExecWithOutput(cwd string, params ...string) (string
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func (e *GitHandlerImpl) GitExecQuietOrPanic(cwd string, params ...string) {
|
||||
e.quiet = true
|
||||
e.GitExecOrPanic(cwd, params...)
|
||||
e.quiet = false
|
||||
return
|
||||
}
|
||||
|
||||
type ChanIO struct {
|
||||
ch chan byte
|
||||
}
|
||||
@@ -761,6 +787,80 @@ func (e *GitHandlerImpl) GitCatFile(cwd, commitId, filename string) (data []byte
|
||||
return
|
||||
}
|
||||
|
||||
// return (filename) -> (hash) map for all submodules
|
||||
func (e *GitHandlerImpl) GitDirectoryList(gitPath, commitId string) (directoryList map[string]string, err error) {
|
||||
var done sync.Mutex
|
||||
directoryList = make(map[string]string)
|
||||
|
||||
done.Lock()
|
||||
data_in, data_out := ChanIO{make(chan byte)}, ChanIO{make(chan byte)}
|
||||
|
||||
LogDebug("Getting directory for:", commitId)
|
||||
|
||||
go func() {
|
||||
defer done.Unlock()
|
||||
defer close(data_out.ch)
|
||||
|
||||
data_out.Write([]byte(commitId))
|
||||
data_out.ch <- '\x00'
|
||||
var c GitCommit
|
||||
c, err = parseGitCommit(data_in.ch)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Error parsing git commit. Err: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
trees := make(map[string]string)
|
||||
trees[""] = c.Tree
|
||||
|
||||
for len(trees) > 0 {
|
||||
for p, tree := range trees {
|
||||
delete(trees, p)
|
||||
|
||||
data_out.Write([]byte(tree))
|
||||
data_out.ch <- '\x00'
|
||||
var tree GitTree
|
||||
tree, err = parseGitTree(data_in.ch)
|
||||
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Error parsing git tree: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, te := range tree.items {
|
||||
if te.isTree() {
|
||||
directoryList[p+te.name] = te.hash
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cmd := exec.Command("/usr/bin/git", "cat-file", "--batch", "-Z")
|
||||
cmd.Env = []string{
|
||||
"GIT_CEILING_DIRECTORIES=" + e.GitPath,
|
||||
"GIT_LFS_SKIP_SMUDGE=1",
|
||||
"GIT_CONFIG_GLOBAL=/dev/null",
|
||||
}
|
||||
cmd.Dir = filepath.Join(e.GitPath, gitPath)
|
||||
cmd.Stdout = &data_in
|
||||
cmd.Stdin = &data_out
|
||||
cmd.Stderr = writeFunc(func(data []byte) (int, error) {
|
||||
LogError(string(data))
|
||||
return len(data), nil
|
||||
})
|
||||
LogDebug("command run:", cmd.Args)
|
||||
if e := cmd.Run(); e != nil {
|
||||
LogError(e)
|
||||
close(data_in.ch)
|
||||
close(data_out.ch)
|
||||
return directoryList, e
|
||||
}
|
||||
|
||||
done.Lock()
|
||||
return directoryList, err
|
||||
}
|
||||
|
||||
// return (filename) -> (hash) map for all submodules
|
||||
func (e *GitHandlerImpl) GitSubmoduleList(gitPath, commitId string) (submoduleList map[string]string, err error) {
|
||||
var done sync.Mutex
|
||||
|
||||
@@ -392,6 +392,7 @@ func TestCommitTreeParsing(t *testing.T) {
|
||||
commitId = commitId + strings.TrimSpace(string(data))
|
||||
return len(data), nil
|
||||
})
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
transport "github.com/go-openapi/runtime/client"
|
||||
@@ -66,6 +67,14 @@ const (
|
||||
ReviewStateUnknown models.ReviewStateType = ""
|
||||
)
|
||||
|
||||
type GiteaLabelGetter interface {
|
||||
GetLabels(org, repo string, idx int64) ([]*models.Label, error)
|
||||
}
|
||||
|
||||
type GiteaLabelSettter interface {
|
||||
SetLabels(org, repo string, idx int64, labels []string) ([]*models.Label, error)
|
||||
}
|
||||
|
||||
type GiteaTimelineFetcher interface {
|
||||
GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error)
|
||||
}
|
||||
@@ -91,9 +100,10 @@ type GiteaPRUpdater interface {
|
||||
UpdatePullRequest(org, repo string, num int64, options *models.EditPullRequestOption) (*models.PullRequest, error)
|
||||
}
|
||||
|
||||
type GiteaPRTimelineFetcher interface {
|
||||
type GiteaPRTimelineReviewFetcher interface {
|
||||
GiteaPRFetcher
|
||||
GiteaTimelineFetcher
|
||||
GiteaReviewFetcher
|
||||
}
|
||||
|
||||
type GiteaCommitFetcher interface {
|
||||
@@ -119,10 +129,16 @@ type GiteaPRChecker interface {
|
||||
GiteaMaintainershipReader
|
||||
}
|
||||
|
||||
type GiteaReviewFetcherAndRequester interface {
|
||||
type GiteaReviewFetcherAndRequesterAndUnrequester interface {
|
||||
GiteaReviewTimelineFetcher
|
||||
GiteaCommentFetcher
|
||||
GiteaReviewRequester
|
||||
GiteaReviewUnrequester
|
||||
}
|
||||
|
||||
type GiteaUnreviewTimelineFetcher interface {
|
||||
GiteaTimelineFetcher
|
||||
GiteaReviewUnrequester
|
||||
}
|
||||
|
||||
type GiteaReviewRequester interface {
|
||||
@@ -160,6 +176,10 @@ type GiteaCommitStatusGetter interface {
|
||||
GetCommitStatus(org, repo, hash string) ([]*models.CommitStatus, error)
|
||||
}
|
||||
|
||||
type GiteaMerger interface {
|
||||
ManualMergePR(org, repo string, id int64, commitid string, delBranch bool) error
|
||||
}
|
||||
|
||||
type Gitea interface {
|
||||
GiteaComment
|
||||
GiteaRepoFetcher
|
||||
@@ -168,6 +188,7 @@ type Gitea interface {
|
||||
GiteaReviewer
|
||||
GiteaPRFetcher
|
||||
GiteaPRUpdater
|
||||
GiteaMerger
|
||||
GiteaCommitFetcher
|
||||
GiteaReviewFetcher
|
||||
GiteaCommentFetcher
|
||||
@@ -177,7 +198,8 @@ type Gitea interface {
|
||||
GiteaCommitStatusGetter
|
||||
GiteaCommitStatusSetter
|
||||
GiteaSetRepoOptions
|
||||
GiteaTimelineFetcher
|
||||
GiteaLabelGetter
|
||||
GiteaLabelSettter
|
||||
|
||||
GetNotifications(Type string, since *time.Time) ([]*models.NotificationThread, error)
|
||||
GetDoneNotifications(Type string, page int64) ([]*models.NotificationThread, error)
|
||||
@@ -185,7 +207,7 @@ type Gitea interface {
|
||||
GetOrganization(orgName string) (*models.Organization, error)
|
||||
GetOrganizationRepositories(orgName string) ([]*models.Repository, error)
|
||||
CreateRepositoryIfNotExist(git Git, org, repoName string) (*models.Repository, error)
|
||||
CreatePullRequestIfNotExist(repo *models.Repository, srcId, targetId, title, body string) (*models.PullRequest, error)
|
||||
CreatePullRequestIfNotExist(repo *models.Repository, srcId, targetId, title, body string) (*models.PullRequest, error, bool)
|
||||
GetPullRequestFileContent(pr *models.PullRequest, path string) ([]byte, string, error)
|
||||
GetRecentPullRequests(org, repo, branch string) ([]*models.PullRequest, error)
|
||||
GetRecentCommits(org, repo, branch string, commitNo int64) ([]*models.Commit, error)
|
||||
@@ -233,6 +255,11 @@ func (gitea *GiteaTransport) GetPullRequest(org, project string, num int64) (*mo
|
||||
gitea.transport.DefaultAuthentication,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
LogError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pr.Payload, err
|
||||
}
|
||||
|
||||
@@ -246,9 +273,36 @@ func (gitea *GiteaTransport) UpdatePullRequest(org, repo string, num int64, opti
|
||||
gitea.transport.DefaultAuthentication,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
LogError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pr.Payload, err
|
||||
}
|
||||
|
||||
func (gitea *GiteaTransport) ManualMergePR(org, repo string, num int64, commitid string, delBranch bool) error {
|
||||
manual_merge := "manually-merged"
|
||||
_, err := gitea.client.Repository.RepoMergePullRequest(
|
||||
repository.NewRepoMergePullRequestParams().
|
||||
WithOwner(org).
|
||||
WithRepo(repo).
|
||||
WithIndex(num).
|
||||
WithBody(&models.MergePullRequestForm{
|
||||
Do: &manual_merge,
|
||||
DeleteBranchAfterMerge: delBranch,
|
||||
HeadCommitID: commitid,
|
||||
}), gitea.transport.DefaultAuthentication,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
LogError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gitea *GiteaTransport) GetPullRequests(org, repo string) ([]*models.PullRequest, error) {
|
||||
var page, limit int64
|
||||
|
||||
@@ -273,6 +327,9 @@ func (gitea *GiteaTransport) GetPullRequests(org, repo string) ([]*models.PullRe
|
||||
return nil, fmt.Errorf("cannot fetch PR list for %s / %s : %w", org, repo, err)
|
||||
}
|
||||
|
||||
if len(req.Payload) == 0 {
|
||||
break
|
||||
}
|
||||
prs = slices.Concat(prs, req.Payload)
|
||||
if len(req.Payload) < int(limit) {
|
||||
break
|
||||
@@ -295,11 +352,11 @@ func (gitea *GiteaTransport) GetCommitStatus(org, repo, hash string) ([]*models.
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
res = append(res, r.Payload...)
|
||||
if len(r.Payload) < int(limit) {
|
||||
if len(r.Payload) == 0 {
|
||||
break
|
||||
}
|
||||
res = append(res, r.Payload...)
|
||||
page++
|
||||
}
|
||||
|
||||
return res, nil
|
||||
@@ -360,10 +417,10 @@ func (gitea *GiteaTransport) GetPullRequestReviews(org, project string, PRnum in
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allReviews = slices.Concat(allReviews, reviews.Payload)
|
||||
if len(reviews.Payload) < int(limit) {
|
||||
if len(reviews.Payload) == 0 {
|
||||
break
|
||||
}
|
||||
allReviews = slices.Concat(allReviews, reviews.Payload)
|
||||
page++
|
||||
}
|
||||
|
||||
@@ -427,6 +484,30 @@ func (gitea *GiteaTransport) SetRepoOptions(owner, repo string, manual_merge boo
|
||||
return ok.Payload, err
|
||||
}
|
||||
|
||||
func (gitea *GiteaTransport) GetLabels(owner, repo string, idx int64) ([]*models.Label, error) {
|
||||
ret, err := gitea.client.Issue.IssueGetLabels(issue.NewIssueGetLabelsParams().WithOwner(owner).WithRepo(repo).WithIndex(idx), gitea.transport.DefaultAuthentication)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret.Payload, err
|
||||
}
|
||||
|
||||
func (gitea *GiteaTransport) SetLabels(owner, repo string, idx int64, labels []string) ([]*models.Label, error) {
|
||||
interfaceLabels := make([]interface{}, len(labels))
|
||||
for i, l := range labels {
|
||||
interfaceLabels[i] = l
|
||||
}
|
||||
|
||||
ret, err := gitea.client.Issue.IssueAddLabel(issue.NewIssueAddLabelParams().WithOwner(owner).WithRepo(repo).WithIndex(idx).WithBody(&models.IssueLabelsOption{Labels: interfaceLabels}),
|
||||
gitea.transport.DefaultAuthentication)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret.Payload, nil
|
||||
}
|
||||
|
||||
const (
|
||||
GiteaNotificationType_Pull = "Pull"
|
||||
)
|
||||
@@ -453,6 +534,9 @@ func (gitea *GiteaTransport) GetNotifications(Type string, since *time.Time) ([]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(list.Payload) == 0 {
|
||||
break
|
||||
}
|
||||
ret = slices.Concat(ret, list.Payload)
|
||||
if len(list.Payload) < int(bigLimit) {
|
||||
break
|
||||
@@ -601,7 +685,7 @@ func (gitea *GiteaTransport) CreateRepositoryIfNotExist(git Git, org, repoName s
|
||||
return repo.Payload, nil
|
||||
}
|
||||
|
||||
func (gitea *GiteaTransport) CreatePullRequestIfNotExist(repo *models.Repository, srcId, targetId, title, body string) (*models.PullRequest, error) {
|
||||
func (gitea *GiteaTransport) CreatePullRequestIfNotExist(repo *models.Repository, srcId, targetId, title, body string) (*models.PullRequest, error, bool) {
|
||||
prOptions := models.CreatePullRequestOption{
|
||||
Base: targetId,
|
||||
Head: srcId,
|
||||
@@ -610,10 +694,14 @@ func (gitea *GiteaTransport) CreatePullRequestIfNotExist(repo *models.Repository
|
||||
}
|
||||
|
||||
if pr, err := gitea.client.Repository.RepoGetPullRequestByBaseHead(
|
||||
repository.NewRepoGetPullRequestByBaseHeadParams().WithOwner(repo.Owner.UserName).WithRepo(repo.Name).WithBase(targetId).WithHead(srcId),
|
||||
repository.NewRepoGetPullRequestByBaseHeadParams().
|
||||
WithOwner(repo.Owner.UserName).
|
||||
WithRepo(repo.Name).
|
||||
WithBase(targetId).
|
||||
WithHead(srcId),
|
||||
gitea.transport.DefaultAuthentication,
|
||||
); err == nil {
|
||||
return pr.Payload, nil
|
||||
); err == nil && pr.Payload.State == "open" {
|
||||
return pr.Payload, nil, false
|
||||
}
|
||||
|
||||
pr, err := gitea.client.Repository.RepoCreatePullRequest(
|
||||
@@ -627,10 +715,10 @@ func (gitea *GiteaTransport) CreatePullRequestIfNotExist(repo *models.Repository
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot create pull request. %w", err)
|
||||
return nil, fmt.Errorf("Cannot create pull request. %w", err), true
|
||||
}
|
||||
|
||||
return pr.GetPayload(), nil
|
||||
return pr.GetPayload(), nil, true
|
||||
}
|
||||
|
||||
func (gitea *GiteaTransport) RequestReviews(pr *models.PullRequest, reviewers ...string) ([]*models.PullReview, error) {
|
||||
@@ -717,39 +805,79 @@ func (gitea *GiteaTransport) AddComment(pr *models.PullRequest, comment string)
|
||||
return nil
|
||||
}
|
||||
|
||||
type TimelineCacheData struct {
|
||||
data []*models.TimelineComment
|
||||
lastCheck time.Time
|
||||
}
|
||||
|
||||
var giteaTimelineCache map[string]TimelineCacheData = make(map[string]TimelineCacheData)
|
||||
var giteaTimelineCacheMutex sync.RWMutex
|
||||
|
||||
// returns timeline in reverse chronological create order
|
||||
func (gitea *GiteaTransport) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) {
|
||||
page := int64(1)
|
||||
resCount := 1
|
||||
|
||||
retData := []*models.TimelineComment{}
|
||||
prID := fmt.Sprintf("%s/%s!%d", org, repo, idx)
|
||||
giteaTimelineCacheMutex.RLock()
|
||||
TimelineCache, IsCached := giteaTimelineCache[prID]
|
||||
var LastCachedTime strfmt.DateTime
|
||||
if IsCached {
|
||||
l := len(TimelineCache.data)
|
||||
if l > 0 {
|
||||
LastCachedTime = TimelineCache.data[0].Updated
|
||||
}
|
||||
|
||||
// cache data for 5 seconds
|
||||
if TimelineCache.lastCheck.Add(time.Second*5).Compare(time.Now()) > 0 {
|
||||
giteaTimelineCacheMutex.RUnlock()
|
||||
return TimelineCache.data, nil
|
||||
}
|
||||
}
|
||||
giteaTimelineCacheMutex.RUnlock()
|
||||
|
||||
giteaTimelineCacheMutex.Lock()
|
||||
defer giteaTimelineCacheMutex.Unlock()
|
||||
|
||||
for resCount > 0 {
|
||||
res, err := gitea.client.Issue.IssueGetCommentsAndTimeline(
|
||||
issue.NewIssueGetCommentsAndTimelineParams().
|
||||
WithOwner(org).
|
||||
WithRepo(repo).
|
||||
WithIndex(idx).
|
||||
WithPage(&page),
|
||||
gitea.transport.DefaultAuthentication,
|
||||
)
|
||||
|
||||
opts := issue.NewIssueGetCommentsAndTimelineParams().WithOwner(org).WithRepo(repo).WithIndex(idx).WithPage(&page)
|
||||
if !LastCachedTime.IsZero() {
|
||||
opts = opts.WithSince(&LastCachedTime)
|
||||
}
|
||||
res, err := gitea.client.Issue.IssueGetCommentsAndTimeline(opts, gitea.transport.DefaultAuthentication)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resCount = len(res.Payload)
|
||||
LogDebug("page:", page, "len:", resCount)
|
||||
if resCount = len(res.Payload); resCount == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, d := range res.Payload {
|
||||
if d != nil {
|
||||
if time.Time(d.Created).Compare(time.Time(LastCachedTime)) > 0 {
|
||||
// created after last check, so we append here
|
||||
TimelineCache.data = append(TimelineCache.data, d)
|
||||
} else {
|
||||
// we need something updated in the timeline, maybe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resCount < 10 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
|
||||
retData = append(retData, res.Payload...)
|
||||
}
|
||||
LogDebug("total results:", len(retData))
|
||||
|
||||
slices.SortFunc(retData, func(a, b *models.TimelineComment) int {
|
||||
LogDebug("timeline", prID, "# timeline:", len(TimelineCache.data))
|
||||
slices.SortFunc(TimelineCache.data, func(a, b *models.TimelineComment) int {
|
||||
return time.Time(b.Created).Compare(time.Time(a.Created))
|
||||
})
|
||||
|
||||
return retData, nil
|
||||
TimelineCache.lastCheck = time.Now()
|
||||
giteaTimelineCache[prID] = TimelineCache
|
||||
|
||||
return TimelineCache.data, nil
|
||||
}
|
||||
|
||||
func (gitea *GiteaTransport) GetRepositoryFileContent(org, repo, hash, path string) ([]byte, string, error) {
|
||||
|
||||
324
common/listen.go
324
common/listen.go
@@ -1,324 +0,0 @@
|
||||
package common
|
||||
|
||||
/*
|
||||
* This file is part of Autogits.
|
||||
*
|
||||
* Copyright © 2024 SUSE LLC
|
||||
*
|
||||
* Autogits is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 2 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Autogits is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Foobar. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
rabbitmq "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const RequestType_CreateBrachTag = "create"
|
||||
const RequestType_DeleteBranchTag = "delete"
|
||||
const RequestType_Fork = "fork"
|
||||
const RequestType_Issue = "issues"
|
||||
const RequestType_IssueAssign = "issue_assign"
|
||||
const RequestType_IssueComment = "issue_comment"
|
||||
const RequestType_IssueLabel = "issue_label"
|
||||
const RequestType_IssueMilestone = "issue_milestone"
|
||||
const RequestType_Push = "push"
|
||||
const RequestType_Repository = "repository"
|
||||
const RequestType_Release = "release"
|
||||
const RequestType_PR = "pull_request"
|
||||
const RequestType_PRAssign = "pull_request_assign"
|
||||
const RequestType_PRLabel = "pull_request_label"
|
||||
const RequestType_PRComment = "pull_request_comment"
|
||||
const RequestType_PRMilestone = "pull_request_milestone"
|
||||
const RequestType_PRSync = "pull_request_sync"
|
||||
const RequestType_PRReviewAccepted = "pull_request_review_approved"
|
||||
const RequestType_PRReviewRejected = "pull_request_review_rejected"
|
||||
const RequestType_PRReviewRequest = "pull_request_review_request"
|
||||
const RequestType_PRReviewComment = "pull_request_review_comment"
|
||||
const RequestType_Wiki = "wiki"
|
||||
|
||||
type RequestProcessor interface {
|
||||
ProcessFunc(*Request) error
|
||||
}
|
||||
|
||||
type ListenDefinitions struct {
|
||||
RabbitURL *url.URL // amqps://user:password@host/queue
|
||||
|
||||
GitAuthor string
|
||||
Handlers map[string]RequestProcessor
|
||||
Orgs []string
|
||||
|
||||
topics []string
|
||||
topicSubChanges chan string // +topic = subscribe, -topic = unsubscribe
|
||||
}
|
||||
|
||||
type RabbitMessage rabbitmq.Delivery
|
||||
|
||||
func (l *ListenDefinitions) processTopicChanges(ch *rabbitmq.Channel, queueName string) {
|
||||
for {
|
||||
topic, ok := <-l.topicSubChanges
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
LogDebug(" topic change:", topic)
|
||||
switch topic[0] {
|
||||
case '+':
|
||||
if err := ch.QueueBind(queueName, topic[1:], "pubsub", false, nil); err != nil {
|
||||
LogError(err)
|
||||
}
|
||||
case '-':
|
||||
if err := ch.QueueUnbind(queueName, topic[1:], "pubsub", nil); err != nil {
|
||||
LogError(err)
|
||||
}
|
||||
default:
|
||||
LogInfo("Ignoring unknown topic change:", topic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ListenDefinitions) processRabbitMQ(msgCh chan<- RabbitMessage) error {
|
||||
queueName := l.RabbitURL.Path
|
||||
l.RabbitURL.Path = ""
|
||||
|
||||
if len(queueName) > 0 && queueName[0] == '/' {
|
||||
queueName = queueName[1:]
|
||||
}
|
||||
|
||||
connection, err := rabbitmq.DialTLS(l.RabbitURL.String(), &tls.Config{
|
||||
ServerName: l.RabbitURL.Hostname(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cannot connect to %s . Err: %w", l.RabbitURL.Hostname(), err)
|
||||
}
|
||||
defer connection.Close()
|
||||
|
||||
ch, err := connection.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cannot create a channel. Err: %w", err)
|
||||
}
|
||||
defer ch.Close()
|
||||
|
||||
if err = ch.ExchangeDeclarePassive("pubsub", "topic", true, false, false, false, nil); err != nil {
|
||||
return fmt.Errorf("Cannot find pubsub exchange? Err: %w", err)
|
||||
}
|
||||
|
||||
var q rabbitmq.Queue
|
||||
if len(queueName) == 0 {
|
||||
q, err = ch.QueueDeclare("", false, true, true, false, nil)
|
||||
} else {
|
||||
q, err = ch.QueueDeclarePassive(queueName, true, false, true, false, nil)
|
||||
if err != nil {
|
||||
LogInfo("queue not found .. trying to create it:", err)
|
||||
if ch.IsClosed() {
|
||||
ch, err = connection.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Channel cannot be re-opened. Err: %w", err)
|
||||
}
|
||||
}
|
||||
q, err = ch.QueueDeclare(queueName, true, false, true, false, nil)
|
||||
|
||||
if err != nil {
|
||||
LogInfo("can't create persistent queue ... falling back to temporaty queue:", err)
|
||||
if ch.IsClosed() {
|
||||
ch, err = connection.Channel()
|
||||
return fmt.Errorf("Channel cannot be re-opened. Err: %w", err)
|
||||
}
|
||||
q, err = ch.QueueDeclare("", false, true, true, false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cannot declare queue. Err: %w", err)
|
||||
}
|
||||
// log.Printf("queue: %s:%d", q.Name, q.Consumers)
|
||||
|
||||
LogDebug(" -- listening to topics:")
|
||||
l.topicSubChanges = make(chan string)
|
||||
defer close(l.topicSubChanges)
|
||||
go l.processTopicChanges(ch, q.Name)
|
||||
|
||||
for _, topic := range l.topics {
|
||||
l.topicSubChanges <- "+" + topic
|
||||
}
|
||||
|
||||
msgs, err := ch.Consume(q.Name, "", true, true, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cannot start consumer. Err: %w", err)
|
||||
}
|
||||
// log.Printf("queue: %s:%d", q.Name, q.Consumers)
|
||||
|
||||
for {
|
||||
msg, ok := <-msgs
|
||||
if !ok {
|
||||
return fmt.Errorf("channel/connection closed?\n")
|
||||
}
|
||||
|
||||
msgCh <- RabbitMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ListenDefinitions) connectAndProcessRabbitMQ(ch chan<- RabbitMessage) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
LogError(r)
|
||||
LogError("'crash' RabbitMQ worker. Recovering... reconnecting...")
|
||||
time.Sleep(5 * time.Second)
|
||||
go l.connectAndProcessRabbitMQ(ch)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
err := l.processRabbitMQ(ch)
|
||||
if err != nil {
|
||||
LogError("Error in RabbitMQ connection. %#v", err)
|
||||
LogInfo("Reconnecting in 2 seconds...")
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ListenDefinitions) connectToRabbitMQ() chan RabbitMessage {
|
||||
ch := make(chan RabbitMessage, 100)
|
||||
go l.connectAndProcessRabbitMQ(ch)
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func ProcessEvent(f RequestProcessor, request *Request) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
LogError("panic caught")
|
||||
if err, ok := r.(error); !ok {
|
||||
LogError(err)
|
||||
}
|
||||
LogError(string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := f.ProcessFunc(request); err != nil {
|
||||
LogError(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (l *ListenDefinitions) generateTopics() []string {
|
||||
topics := make([]string, 0, len(l.Handlers)*len(l.Orgs))
|
||||
scope := "suse"
|
||||
if l.RabbitURL.Hostname() == "rabbit.opensuse.org" {
|
||||
scope = "opensuse"
|
||||
}
|
||||
|
||||
for _, org := range l.Orgs {
|
||||
for requestType, _ := range l.Handlers {
|
||||
topics = append(topics, fmt.Sprintf("%s.src.%s.%s.#", scope, org, requestType))
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(topics)
|
||||
return slices.Compact(topics)
|
||||
}
|
||||
|
||||
func (l *ListenDefinitions) UpdateTopics() {
|
||||
newTopics := l.generateTopics()
|
||||
|
||||
j := 0
|
||||
next_new_topic:
|
||||
for i := 0; i < len(newTopics); i++ {
|
||||
topic := newTopics[i]
|
||||
|
||||
for j < len(l.topics) {
|
||||
cmp := strings.Compare(topic, l.topics[j])
|
||||
|
||||
if cmp == 0 {
|
||||
j++
|
||||
continue next_new_topic
|
||||
}
|
||||
|
||||
if cmp < 0 {
|
||||
l.topicSubChanges <- "+" + topic
|
||||
break
|
||||
}
|
||||
|
||||
l.topicSubChanges <- "-" + l.topics[j]
|
||||
j++
|
||||
}
|
||||
|
||||
if j == len(l.topics) {
|
||||
l.topicSubChanges <- "+" + topic
|
||||
}
|
||||
}
|
||||
|
||||
for j < len(l.topics) {
|
||||
l.topicSubChanges <- "-" + l.topics[j]
|
||||
j++
|
||||
}
|
||||
|
||||
l.topics = newTopics
|
||||
}
|
||||
|
||||
func (l *ListenDefinitions) ProcessRabbitMQEvents() error {
|
||||
LogInfo("RabbitMQ connection:", l.RabbitURL.String())
|
||||
LogDebug("# Handlers:", len(l.Handlers))
|
||||
LogDebug("# Orgs:", len(l.Orgs))
|
||||
|
||||
l.RabbitURL.User = url.UserPassword(rabbitUser, rabbitPassword)
|
||||
l.topics = l.generateTopics()
|
||||
ch := l.connectToRabbitMQ()
|
||||
|
||||
for {
|
||||
msg, ok := <-ch
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
LogDebug("event:", msg.RoutingKey)
|
||||
|
||||
route := strings.Split(msg.RoutingKey, ".")
|
||||
if len(route) > 3 {
|
||||
reqType := route[3]
|
||||
org := route[2]
|
||||
|
||||
if !slices.Contains(l.Orgs, org) {
|
||||
LogInfo("Got event for unhandeled org:", org)
|
||||
continue
|
||||
}
|
||||
|
||||
LogDebug("org:", org, "type:", reqType)
|
||||
if handler, found := l.Handlers[reqType]; found {
|
||||
/* h, err := CreateRequestHandler()
|
||||
if err != nil {
|
||||
log.Println("Cannot create request handler", err)
|
||||
continue
|
||||
}
|
||||
*/
|
||||
req, err := ParseRequestJSON(reqType, msg.Body)
|
||||
if err != nil {
|
||||
LogError("Error parsing request JSON:", err)
|
||||
continue
|
||||
} else {
|
||||
LogDebug("processing req", req.Type)
|
||||
// h.Request = req
|
||||
ProcessEvent(handler, req)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,10 @@ func SetLoggingLevel(ll LogLevel) {
|
||||
logLevel = ll
|
||||
}
|
||||
|
||||
func GetLoggingLevel() LogLevel {
|
||||
return logLevel
|
||||
}
|
||||
|
||||
func SetLoggingLevelFromString(ll string) error {
|
||||
switch ll {
|
||||
case "info":
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
//go:generate mockgen -source=maintainership.go -destination=mock/maintainership.go -typed
|
||||
|
||||
type MaintainershipData interface {
|
||||
ListProjectMaintainers() []string
|
||||
ListPackageMaintainers(pkg string) []string
|
||||
ListProjectMaintainers(OptionalGroupExpansion []*ReviewGroup) []string
|
||||
ListPackageMaintainers(Pkg string, OptionalGroupExpasion []*ReviewGroup) []string
|
||||
|
||||
IsApproved(pkg string, reviews []*models.PullReview, submitter string) bool
|
||||
IsApproved(Pkg string, Reviews []*models.PullReview, Submitter string, ReviewGroups []*ReviewGroup) bool
|
||||
}
|
||||
|
||||
const ProjectKey = ""
|
||||
@@ -70,7 +70,7 @@ func FetchProjectMaintainershipData(gitea GiteaMaintainershipReader, org, prjGit
|
||||
return m, err
|
||||
}
|
||||
|
||||
func (data *MaintainershipMap) ListProjectMaintainers() []string {
|
||||
func (data *MaintainershipMap) ListProjectMaintainers(groups []*ReviewGroup) []string {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -80,6 +80,11 @@ func (data *MaintainershipMap) ListProjectMaintainers() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// expands groups
|
||||
for _, g := range groups {
|
||||
m = g.ExpandMaintainers(m)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -96,7 +101,7 @@ func parsePkgDirData(pkg string, data []byte) []string {
|
||||
return pkgMaintainers
|
||||
}
|
||||
|
||||
func (data *MaintainershipMap) ListPackageMaintainers(pkg string) []string {
|
||||
func (data *MaintainershipMap) ListPackageMaintainers(pkg string, groups []*ReviewGroup) []string {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -111,7 +116,7 @@ func (data *MaintainershipMap) ListPackageMaintainers(pkg string) []string {
|
||||
}
|
||||
}
|
||||
}
|
||||
prjMaintainers := data.ListProjectMaintainers()
|
||||
prjMaintainers := data.ListProjectMaintainers(nil)
|
||||
|
||||
prjMaintainer:
|
||||
for _, prjm := range prjMaintainers {
|
||||
@@ -123,15 +128,20 @@ prjMaintainer:
|
||||
pkgMaintainers = append(pkgMaintainers, prjm)
|
||||
}
|
||||
|
||||
// expands groups
|
||||
for _, g := range groups {
|
||||
pkgMaintainers = g.ExpandMaintainers(pkgMaintainers)
|
||||
}
|
||||
|
||||
return pkgMaintainers
|
||||
}
|
||||
|
||||
func (data *MaintainershipMap) IsApproved(pkg string, reviews []*models.PullReview, submitter string) bool {
|
||||
func (data *MaintainershipMap) IsApproved(pkg string, reviews []*models.PullReview, submitter string, groups []*ReviewGroup) bool {
|
||||
var reviewers []string
|
||||
if pkg != ProjectKey {
|
||||
reviewers = data.ListPackageMaintainers(pkg)
|
||||
reviewers = data.ListPackageMaintainers(pkg, groups)
|
||||
} else {
|
||||
reviewers = data.ListProjectMaintainers()
|
||||
reviewers = data.ListProjectMaintainers(groups)
|
||||
}
|
||||
|
||||
if len(reviewers) == 0 {
|
||||
|
||||
@@ -28,6 +28,8 @@ func TestMaintainership(t *testing.T) {
|
||||
maintainersFile []byte
|
||||
maintainersFileErr error
|
||||
|
||||
groups []*common.ReviewGroup
|
||||
|
||||
maintainersDir map[string][]byte
|
||||
}{
|
||||
/* PACKAGE MAINTAINERS */
|
||||
@@ -51,6 +53,22 @@ func TestMaintainership(t *testing.T) {
|
||||
maintainers: []string{"user1", "user2", "user3"},
|
||||
packageName: "pkg",
|
||||
},
|
||||
{
|
||||
name: "Multiple package maintainers and groups",
|
||||
maintainersFile: []byte(`{"pkg": ["user1", "user2", "g2"], "": ["g2", "user1", "user3"]}`),
|
||||
maintainersDir: map[string][]byte{
|
||||
"_project": []byte(`{"": ["user1", "user3", "g2"]}`),
|
||||
"pkg": []byte(`{"pkg": ["user1", "g2", "user2"]}`),
|
||||
},
|
||||
maintainers: []string{"user1", "user2", "user3", "user5"},
|
||||
packageName: "pkg",
|
||||
groups: []*common.ReviewGroup{
|
||||
{
|
||||
Name: "g2",
|
||||
Reviewers: []string{"user1", "user5"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No package maintainers and only project maintainer",
|
||||
maintainersFile: []byte(`{"pkg2": ["user1", "user2"], "": ["user1", "user3"]}`),
|
||||
@@ -138,9 +156,9 @@ func TestMaintainership(t *testing.T) {
|
||||
|
||||
var m []string
|
||||
if len(test.packageName) > 0 {
|
||||
m = maintainers.ListPackageMaintainers(test.packageName)
|
||||
m = maintainers.ListPackageMaintainers(test.packageName, test.groups)
|
||||
} else {
|
||||
m = maintainers.ListProjectMaintainers()
|
||||
m = maintainers.ListProjectMaintainers(test.groups)
|
||||
}
|
||||
|
||||
if len(m) != len(test.maintainers) {
|
||||
@@ -207,7 +225,7 @@ func TestMaintainershipFileWrite(t *testing.T) {
|
||||
{
|
||||
name: "2 project maintainers and 2 single package maintainers",
|
||||
maintainers: map[string][]string{
|
||||
"": {"two", "one"},
|
||||
"": {"two", "one"},
|
||||
"pkg1": {},
|
||||
"foo": {"four", "byte"},
|
||||
},
|
||||
|
||||
56
common/manifest.go
Normal file
56
common/manifest.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
Subdirectories []string
|
||||
}
|
||||
|
||||
func (m *Manifest) SubdirForPackage(pkg string) string {
|
||||
if m == nil {
|
||||
return pkg
|
||||
}
|
||||
|
||||
idx := -1
|
||||
matchLen := 0
|
||||
basePkg := path.Base(pkg)
|
||||
lowercasePkg := strings.ToLower(basePkg)
|
||||
|
||||
for i, sub := range m.Subdirectories {
|
||||
basename := strings.ToLower(path.Base(sub))
|
||||
if strings.HasPrefix(lowercasePkg, basename) && matchLen < len(basename) {
|
||||
idx = i
|
||||
matchLen = len(basename)
|
||||
}
|
||||
}
|
||||
|
||||
if idx > -1 {
|
||||
return path.Join(m.Subdirectories[idx], basePkg)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func ReadManifestFile(filename string) (*Manifest, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ParseManifestFile(data)
|
||||
}
|
||||
|
||||
func ParseManifestFile(data []byte) (*Manifest, error) {
|
||||
ret := &Manifest{}
|
||||
err := yaml.Unmarshal(data, ret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
56
common/manifest_test.go
Normal file
56
common/manifest_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
func TestManifestSubdirAssignments(t *testing.T) {
|
||||
tests := []struct {
|
||||
Name string
|
||||
ManifestContent string
|
||||
Packages []string
|
||||
ManifestLocations []string
|
||||
}{
|
||||
{
|
||||
Name: "empty manifest",
|
||||
Packages: []string{"atom", "blarg", "Foobar", "X-Ray", "boost", "NodeJS"},
|
||||
ManifestLocations: []string{"atom", "blarg", "Foobar", "X-Ray", "boost", "NodeJS"},
|
||||
},
|
||||
{
|
||||
Name: "only few subdirs manifest",
|
||||
ManifestContent: "subdirectories:\n - a\n - b",
|
||||
Packages: []string{"atom", "blarg", "Foobar", "X-Ray", "Boost", "NodeJS"},
|
||||
ManifestLocations: []string{"a/atom", "b/blarg", "Foobar", "X-Ray", "b/Boost", "NodeJS"},
|
||||
},
|
||||
{
|
||||
Name: "multilayer subdirs manifest",
|
||||
ManifestContent: "subdirectories:\n - a\n - b\n - libs/boo",
|
||||
Packages: []string{"atom", "blarg", "Foobar", "X-Ray", "Boost", "NodeJS"},
|
||||
ManifestLocations: []string{"a/atom", "b/blarg", "Foobar", "X-Ray", "libs/boo/Boost", "NodeJS"},
|
||||
},
|
||||
{
|
||||
Name: "multilayer subdirs manifest with trailing /",
|
||||
ManifestContent: "subdirectories:\n - a\n - b\n - libs/boo/\n - somedir/Node/",
|
||||
Packages: []string{"atom", "blarg", "Foobar", "X-Ray", "Boost", "NodeJS", "foobar/node2"},
|
||||
ManifestLocations: []string{"a/atom", "b/blarg", "Foobar", "X-Ray", "libs/boo/Boost", "somedir/Node/NodeJS", "somedir/Node/node2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
m, err := common.ParseManifestFile([]byte(test.ManifestContent))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i, pkg := range test.Packages {
|
||||
expected := test.ManifestLocations[i]
|
||||
if l := m.SubdirForPackage(pkg); l != expected {
|
||||
t.Error("Expected:", expected, "but got:", l)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
120
common/mock/config.go
Normal file
120
common/mock/config.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: config.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -source=config.go -destination=mock/config.go -typed
|
||||
//
|
||||
|
||||
// Package mock_common is a generated GoMock package.
|
||||
package mock_common
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
models "src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
)
|
||||
|
||||
// MockGiteaFileContentAndRepoFetcher is a mock of GiteaFileContentAndRepoFetcher interface.
|
||||
type MockGiteaFileContentAndRepoFetcher struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockGiteaFileContentAndRepoFetcherMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockGiteaFileContentAndRepoFetcherMockRecorder is the mock recorder for MockGiteaFileContentAndRepoFetcher.
|
||||
type MockGiteaFileContentAndRepoFetcherMockRecorder struct {
|
||||
mock *MockGiteaFileContentAndRepoFetcher
|
||||
}
|
||||
|
||||
// NewMockGiteaFileContentAndRepoFetcher creates a new mock instance.
|
||||
func NewMockGiteaFileContentAndRepoFetcher(ctrl *gomock.Controller) *MockGiteaFileContentAndRepoFetcher {
|
||||
mock := &MockGiteaFileContentAndRepoFetcher{ctrl: ctrl}
|
||||
mock.recorder = &MockGiteaFileContentAndRepoFetcherMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockGiteaFileContentAndRepoFetcher) EXPECT() *MockGiteaFileContentAndRepoFetcherMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetRepository mocks base method.
|
||||
func (m *MockGiteaFileContentAndRepoFetcher) GetRepository(org, repo string) (*models.Repository, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetRepository", org, repo)
|
||||
ret0, _ := ret[0].(*models.Repository)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetRepository indicates an expected call of GetRepository.
|
||||
func (mr *MockGiteaFileContentAndRepoFetcherMockRecorder) GetRepository(org, repo any) *MockGiteaFileContentAndRepoFetcherGetRepositoryCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRepository", reflect.TypeOf((*MockGiteaFileContentAndRepoFetcher)(nil).GetRepository), org, repo)
|
||||
return &MockGiteaFileContentAndRepoFetcherGetRepositoryCall{Call: call}
|
||||
}
|
||||
|
||||
// MockGiteaFileContentAndRepoFetcherGetRepositoryCall wrap *gomock.Call
|
||||
type MockGiteaFileContentAndRepoFetcherGetRepositoryCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockGiteaFileContentAndRepoFetcherGetRepositoryCall) Return(arg0 *models.Repository, arg1 error) *MockGiteaFileContentAndRepoFetcherGetRepositoryCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockGiteaFileContentAndRepoFetcherGetRepositoryCall) Do(f func(string, string) (*models.Repository, error)) *MockGiteaFileContentAndRepoFetcherGetRepositoryCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockGiteaFileContentAndRepoFetcherGetRepositoryCall) DoAndReturn(f func(string, string) (*models.Repository, error)) *MockGiteaFileContentAndRepoFetcherGetRepositoryCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// GetRepositoryFileContent mocks base method.
|
||||
func (m *MockGiteaFileContentAndRepoFetcher) GetRepositoryFileContent(org, repo, hash, path string) ([]byte, string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetRepositoryFileContent", org, repo, hash, path)
|
||||
ret0, _ := ret[0].([]byte)
|
||||
ret1, _ := ret[1].(string)
|
||||
ret2, _ := ret[2].(error)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// GetRepositoryFileContent indicates an expected call of GetRepositoryFileContent.
|
||||
func (mr *MockGiteaFileContentAndRepoFetcherMockRecorder) GetRepositoryFileContent(org, repo, hash, path any) *MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRepositoryFileContent", reflect.TypeOf((*MockGiteaFileContentAndRepoFetcher)(nil).GetRepositoryFileContent), org, repo, hash, path)
|
||||
return &MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall{Call: call}
|
||||
}
|
||||
|
||||
// MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall wrap *gomock.Call
|
||||
type MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall) Return(arg0 []byte, arg1 string, arg2 error) *MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall {
|
||||
c.Call = c.Call.Return(arg0, arg1, arg2)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall) Do(f func(string, string, string, string) ([]byte, string, error)) *MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall) DoAndReturn(f func(string, string, string, string) ([]byte, string, error)) *MockGiteaFileContentAndRepoFetcherGetRepositoryFileContentCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
1148
common/mock/git_utils.go
Normal file
1148
common/mock/git_utils.go
Normal file
File diff suppressed because it is too large
Load Diff
3346
common/mock/gitea_utils.go
Normal file
3346
common/mock/gitea_utils.go
Normal file
File diff suppressed because it is too large
Load Diff
156
common/mock/maintainership.go
Normal file
156
common/mock/maintainership.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: maintainership.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -source=maintainership.go -destination=mock/maintainership.go -typed
|
||||
//
|
||||
|
||||
// Package mock_common is a generated GoMock package.
|
||||
package mock_common
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
common "src.opensuse.org/autogits/common"
|
||||
models "src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
)
|
||||
|
||||
// MockMaintainershipData is a mock of MaintainershipData interface.
|
||||
type MockMaintainershipData struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockMaintainershipDataMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockMaintainershipDataMockRecorder is the mock recorder for MockMaintainershipData.
|
||||
type MockMaintainershipDataMockRecorder struct {
|
||||
mock *MockMaintainershipData
|
||||
}
|
||||
|
||||
// NewMockMaintainershipData creates a new mock instance.
|
||||
func NewMockMaintainershipData(ctrl *gomock.Controller) *MockMaintainershipData {
|
||||
mock := &MockMaintainershipData{ctrl: ctrl}
|
||||
mock.recorder = &MockMaintainershipDataMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockMaintainershipData) EXPECT() *MockMaintainershipDataMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// IsApproved mocks base method.
|
||||
func (m *MockMaintainershipData) IsApproved(Pkg string, Reviews []*models.PullReview, Submitter string, ReviewGroups []*common.ReviewGroup) bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "IsApproved", Pkg, Reviews, Submitter, ReviewGroups)
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// IsApproved indicates an expected call of IsApproved.
|
||||
func (mr *MockMaintainershipDataMockRecorder) IsApproved(Pkg, Reviews, Submitter, ReviewGroups any) *MockMaintainershipDataIsApprovedCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsApproved", reflect.TypeOf((*MockMaintainershipData)(nil).IsApproved), Pkg, Reviews, Submitter, ReviewGroups)
|
||||
return &MockMaintainershipDataIsApprovedCall{Call: call}
|
||||
}
|
||||
|
||||
// MockMaintainershipDataIsApprovedCall wrap *gomock.Call
|
||||
type MockMaintainershipDataIsApprovedCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockMaintainershipDataIsApprovedCall) Return(arg0 bool) *MockMaintainershipDataIsApprovedCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockMaintainershipDataIsApprovedCall) Do(f func(string, []*models.PullReview, string, []*common.ReviewGroup) bool) *MockMaintainershipDataIsApprovedCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockMaintainershipDataIsApprovedCall) DoAndReturn(f func(string, []*models.PullReview, string, []*common.ReviewGroup) bool) *MockMaintainershipDataIsApprovedCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// ListPackageMaintainers mocks base method.
|
||||
func (m *MockMaintainershipData) ListPackageMaintainers(Pkg string, OptionalGroupExpasion []*common.ReviewGroup) []string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListPackageMaintainers", Pkg, OptionalGroupExpasion)
|
||||
ret0, _ := ret[0].([]string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ListPackageMaintainers indicates an expected call of ListPackageMaintainers.
|
||||
func (mr *MockMaintainershipDataMockRecorder) ListPackageMaintainers(Pkg, OptionalGroupExpasion any) *MockMaintainershipDataListPackageMaintainersCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPackageMaintainers", reflect.TypeOf((*MockMaintainershipData)(nil).ListPackageMaintainers), Pkg, OptionalGroupExpasion)
|
||||
return &MockMaintainershipDataListPackageMaintainersCall{Call: call}
|
||||
}
|
||||
|
||||
// MockMaintainershipDataListPackageMaintainersCall wrap *gomock.Call
|
||||
type MockMaintainershipDataListPackageMaintainersCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockMaintainershipDataListPackageMaintainersCall) Return(arg0 []string) *MockMaintainershipDataListPackageMaintainersCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockMaintainershipDataListPackageMaintainersCall) Do(f func(string, []*common.ReviewGroup) []string) *MockMaintainershipDataListPackageMaintainersCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockMaintainershipDataListPackageMaintainersCall) DoAndReturn(f func(string, []*common.ReviewGroup) []string) *MockMaintainershipDataListPackageMaintainersCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// ListProjectMaintainers mocks base method.
|
||||
func (m *MockMaintainershipData) ListProjectMaintainers(OptionalGroupExpansion []*common.ReviewGroup) []string {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListProjectMaintainers", OptionalGroupExpansion)
|
||||
ret0, _ := ret[0].([]string)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ListProjectMaintainers indicates an expected call of ListProjectMaintainers.
|
||||
func (mr *MockMaintainershipDataMockRecorder) ListProjectMaintainers(OptionalGroupExpansion any) *MockMaintainershipDataListProjectMaintainersCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProjectMaintainers", reflect.TypeOf((*MockMaintainershipData)(nil).ListProjectMaintainers), OptionalGroupExpansion)
|
||||
return &MockMaintainershipDataListProjectMaintainersCall{Call: call}
|
||||
}
|
||||
|
||||
// MockMaintainershipDataListProjectMaintainersCall wrap *gomock.Call
|
||||
type MockMaintainershipDataListProjectMaintainersCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockMaintainershipDataListProjectMaintainersCall) Return(arg0 []string) *MockMaintainershipDataListProjectMaintainersCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockMaintainershipDataListProjectMaintainersCall) Do(f func([]*common.ReviewGroup) []string) *MockMaintainershipDataListProjectMaintainersCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockMaintainershipDataListProjectMaintainersCall) DoAndReturn(f func([]*common.ReviewGroup) []string) *MockMaintainershipDataListProjectMaintainersCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
85
common/mock/obs_utils.go
Normal file
85
common/mock/obs_utils.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: obs_utils.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -source=obs_utils.go -destination=mock/obs_utils.go -typed
|
||||
//
|
||||
|
||||
// Package mock_common is a generated GoMock package.
|
||||
package mock_common
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
common "src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
// MockObsStatusFetcherWithState is a mock of ObsStatusFetcherWithState interface.
|
||||
type MockObsStatusFetcherWithState struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockObsStatusFetcherWithStateMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockObsStatusFetcherWithStateMockRecorder is the mock recorder for MockObsStatusFetcherWithState.
|
||||
type MockObsStatusFetcherWithStateMockRecorder struct {
|
||||
mock *MockObsStatusFetcherWithState
|
||||
}
|
||||
|
||||
// NewMockObsStatusFetcherWithState creates a new mock instance.
|
||||
func NewMockObsStatusFetcherWithState(ctrl *gomock.Controller) *MockObsStatusFetcherWithState {
|
||||
mock := &MockObsStatusFetcherWithState{ctrl: ctrl}
|
||||
mock.recorder = &MockObsStatusFetcherWithStateMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockObsStatusFetcherWithState) EXPECT() *MockObsStatusFetcherWithStateMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// BuildStatusWithState mocks base method.
|
||||
func (m *MockObsStatusFetcherWithState) BuildStatusWithState(project string, opts *common.BuildResultOptions, packages ...string) (*common.BuildResultList, error) {
|
||||
m.ctrl.T.Helper()
|
||||
varargs := []any{project, opts}
|
||||
for _, a := range packages {
|
||||
varargs = append(varargs, a)
|
||||
}
|
||||
ret := m.ctrl.Call(m, "BuildStatusWithState", varargs...)
|
||||
ret0, _ := ret[0].(*common.BuildResultList)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// BuildStatusWithState indicates an expected call of BuildStatusWithState.
|
||||
func (mr *MockObsStatusFetcherWithStateMockRecorder) BuildStatusWithState(project, opts any, packages ...any) *MockObsStatusFetcherWithStateBuildStatusWithStateCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
varargs := append([]any{project, opts}, packages...)
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildStatusWithState", reflect.TypeOf((*MockObsStatusFetcherWithState)(nil).BuildStatusWithState), varargs...)
|
||||
return &MockObsStatusFetcherWithStateBuildStatusWithStateCall{Call: call}
|
||||
}
|
||||
|
||||
// MockObsStatusFetcherWithStateBuildStatusWithStateCall wrap *gomock.Call
|
||||
type MockObsStatusFetcherWithStateBuildStatusWithStateCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockObsStatusFetcherWithStateBuildStatusWithStateCall) Return(arg0 *common.BuildResultList, arg1 error) *MockObsStatusFetcherWithStateBuildStatusWithStateCall {
|
||||
c.Call = c.Call.Return(arg0, arg1)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockObsStatusFetcherWithStateBuildStatusWithStateCall) Do(f func(string, *common.BuildResultOptions, ...string) (*common.BuildResultList, error)) *MockObsStatusFetcherWithStateBuildStatusWithStateCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockObsStatusFetcherWithStateBuildStatusWithStateCall) DoAndReturn(f func(string, *common.BuildResultOptions, ...string) (*common.BuildResultList, error)) *MockObsStatusFetcherWithStateBuildStatusWithStateCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
@@ -127,10 +127,12 @@ type ProjectMeta struct {
|
||||
Groups []GroupRepoMeta `xml:"group"`
|
||||
Repositories []RepositoryMeta `xml:"repository"`
|
||||
|
||||
BuildFlags Flags `xml:"build"`
|
||||
PublicFlags Flags `xml:"publish"`
|
||||
DebugFlags Flags `xml:"debuginfo"`
|
||||
UseForBuild Flags `xml:"useforbuild"`
|
||||
BuildFlags Flags `xml:"build"`
|
||||
PublicFlags Flags `xml:"publish"`
|
||||
DebugFlags Flags `xml:"debuginfo"`
|
||||
UseForBuild Flags `xml:"useforbuild"`
|
||||
Access Flags `xml:"access"`
|
||||
SourceAccess Flags `xml:"sourceaccess"`
|
||||
}
|
||||
|
||||
type PackageMeta struct {
|
||||
@@ -140,6 +142,12 @@ type PackageMeta struct {
|
||||
ScmSync string `xml:"scmsync"`
|
||||
Persons []PersonRepoMeta `xml:"person"`
|
||||
Groups []GroupRepoMeta `xml:"group"`
|
||||
|
||||
BuildFlags Flags `xml:"build"`
|
||||
PublicFlags Flags `xml:"publish"`
|
||||
DebugFlags Flags `xml:"debuginfo"`
|
||||
UseForBuild Flags `xml:"useforbuild"`
|
||||
SourceAccess Flags `xml:"sourceaccess"`
|
||||
}
|
||||
|
||||
type UserMeta struct {
|
||||
@@ -562,25 +570,58 @@ func (c *ObsClient) DeleteProject(project string) error {
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ObsClient) BuildLog(prj, pkg, repo, arch string) (io.ReadCloser, error) {
|
||||
url := c.baseUrl.JoinPath("build", prj, repo, arch, pkg, "_log")
|
||||
query := url.Query()
|
||||
query.Add("nostream", "1")
|
||||
query.Add("start", "0")
|
||||
url.RawQuery = query.Encode()
|
||||
res, err := c.ObsRequestRaw("GET", url.String(), nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Body, nil
|
||||
}
|
||||
|
||||
type PackageBuildStatus struct {
|
||||
Package string `xml:"package,attr"`
|
||||
Code string `xml:"code,attr"`
|
||||
Details string `xml:"details"`
|
||||
|
||||
LastUpdate time.Time
|
||||
}
|
||||
|
||||
func PackageBuildStatusComp(A, B *PackageBuildStatus) int {
|
||||
return strings.Compare(A.Package, B.Package)
|
||||
}
|
||||
|
||||
type BuildResult struct {
|
||||
Project string `xml:"project,attr"`
|
||||
Repository string `xml:"repository,attr"`
|
||||
Arch string `xml:"arch,attr"`
|
||||
Code string `xml:"code,attr"`
|
||||
Dirty bool `xml:"dirty,attr"`
|
||||
ScmSync string `xml:"scmsync"`
|
||||
ScmInfo string `xml:"scminfo"`
|
||||
Status []PackageBuildStatus `xml:"status"`
|
||||
Binaries []BinaryList `xml:"binarylist"`
|
||||
XMLName xml.Name `xml:"result" json:"xml,omitempty"`
|
||||
Project string `xml:"project,attr"`
|
||||
Repository string `xml:"repository,attr"`
|
||||
Arch string `xml:"arch,attr"`
|
||||
Code string `xml:"code,attr"`
|
||||
Dirty bool `xml:"dirty,attr,omitempty"`
|
||||
ScmSync string `xml:"scmsync,omitempty"`
|
||||
ScmInfo string `xml:"scminfo,omitempty"`
|
||||
Status []*PackageBuildStatus `xml:"status"`
|
||||
Binaries []BinaryList `xml:"binarylist,omitempty"`
|
||||
|
||||
LastUpdate time.Time
|
||||
}
|
||||
|
||||
func BuildResultComp(A, B *BuildResult) int {
|
||||
if cmp := strings.Compare(A.Project, B.Project); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
if cmp := strings.Compare(A.Repository, B.Repository); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
return strings.Compare(A.Arch, B.Arch)
|
||||
}
|
||||
|
||||
type Binary struct {
|
||||
@@ -595,9 +636,9 @@ type BinaryList struct {
|
||||
}
|
||||
|
||||
type BuildResultList struct {
|
||||
XMLName xml.Name `xml:"resultlist"`
|
||||
State string `xml:"state,attr"`
|
||||
Result []BuildResult `xml:"result"`
|
||||
XMLName xml.Name `xml:"resultlist"`
|
||||
State string `xml:"state,attr"`
|
||||
Result []*BuildResult `xml:"result"`
|
||||
|
||||
isLastBuild bool
|
||||
}
|
||||
|
||||
313
common/pr.go
313
common/pr.go
@@ -9,6 +9,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"src.opensuse.org/autogits/common/gitea-generated/client/repository"
|
||||
"src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
)
|
||||
|
||||
@@ -22,7 +23,50 @@ type PRSet struct {
|
||||
PRs []*PRInfo
|
||||
Config *AutogitConfig
|
||||
|
||||
BotUser string
|
||||
BotUser string
|
||||
HasAutoStaging bool
|
||||
}
|
||||
|
||||
func (prinfo *PRInfo) PRComponents() (org string, repo string, idx int64) {
|
||||
org = prinfo.PR.Base.Repo.Owner.UserName
|
||||
repo = prinfo.PR.Base.Repo.Name
|
||||
idx = prinfo.PR.Index
|
||||
return
|
||||
}
|
||||
|
||||
func (prinfo *PRInfo) RemoveReviewers(gitea GiteaUnreviewTimelineFetcher, Reviewers []string, BotUser string) {
|
||||
org, repo, idx := prinfo.PRComponents()
|
||||
tl, err := gitea.GetTimeline(org, repo, idx)
|
||||
if err != nil {
|
||||
LogError("Failed to fetch timeline for", PRtoString(prinfo.PR), err)
|
||||
}
|
||||
|
||||
// find review request for each reviewer
|
||||
ReviewersToUnrequest := Reviewers
|
||||
ReviewersAlreadyChecked := []string{}
|
||||
|
||||
for _, tlc := range tl {
|
||||
if tlc.Type == TimelineCommentType_ReviewRequested && tlc.Assignee != nil {
|
||||
user := tlc.Assignee.UserName
|
||||
|
||||
if idx := slices.Index(ReviewersToUnrequest, user); idx >= 0 && !slices.Contains(ReviewersAlreadyChecked, user) {
|
||||
if tlc.User != nil && tlc.User.UserName == BotUser {
|
||||
ReviewersAlreadyChecked = append(ReviewersAlreadyChecked, user)
|
||||
continue
|
||||
}
|
||||
ReviewersToUnrequest = slices.Delete(ReviewersToUnrequest, idx, idx+1)
|
||||
if len(Reviewers) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("Unrequesting reviewes for", PRtoString(prinfo.PR), ReviewersToUnrequest)
|
||||
err = gitea.UnrequestReview(org, repo, idx, ReviewersToUnrequest...)
|
||||
if err != nil {
|
||||
LogError("Failed to unrequest reviewers for", PRtoString(prinfo.PR), err)
|
||||
}
|
||||
}
|
||||
|
||||
func readPRData(gitea GiteaPRFetcher, pr *models.PullRequest, currentSet []*PRInfo, config *AutogitConfig) ([]*PRInfo, error) {
|
||||
@@ -55,14 +99,15 @@ func readPRData(gitea GiteaPRFetcher, pr *models.PullRequest, currentSet []*PRIn
|
||||
|
||||
var Timeline_RefIssueNotFound error = errors.New("RefIssue not found on the timeline")
|
||||
|
||||
func LastPrjGitRefOnTimeline(gitea GiteaPRTimelineFetcher, org, repo string, num int64, prjGitOrg, prjGitRepo string) (*models.PullRequest, error) {
|
||||
prRefLine := fmt.Sprintf(PrPattern, org, repo, num)
|
||||
func LastPrjGitRefOnTimeline(botUser string, gitea GiteaPRTimelineReviewFetcher, org, repo string, num int64, config *AutogitConfig) (*models.PullRequest, error) {
|
||||
timeline, err := gitea.GetTimeline(org, repo, num)
|
||||
if err != nil {
|
||||
LogError("Failed to fetch timeline for", org, repo, "#", num, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prjGitOrg, prjGitRepo, prjGitBranch := config.GetPrjGit()
|
||||
|
||||
for idx := len(timeline) - 1; idx >= 0; idx-- {
|
||||
item := timeline[idx]
|
||||
issue := item.RefIssue
|
||||
@@ -72,9 +117,32 @@ func LastPrjGitRefOnTimeline(gitea GiteaPRTimelineFetcher, org, repo string, num
|
||||
issue.Repository.Owner == prjGitOrg &&
|
||||
issue.Repository.Name == prjGitRepo {
|
||||
|
||||
lines := SplitLines(item.RefIssue.Body)
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == prRefLine {
|
||||
if !config.NoProjectGitPR {
|
||||
if issue.User.UserName != botUser {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
pr, err := gitea.GetPullRequest(prjGitOrg, prjGitRepo, issue.Index)
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
case *repository.RepoGetPullRequestNotFound: // deleted?
|
||||
continue
|
||||
default:
|
||||
LogDebug("PrjGit RefIssue fetch error from timeline", issue.Index, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("found ref PR on timeline:", PRtoString(pr))
|
||||
if pr.Base.Name != prjGitBranch {
|
||||
LogDebug(" -> not matching:", pr.Base.Name, prjGitBranch)
|
||||
continue
|
||||
}
|
||||
|
||||
_, prs := ExtractDescriptionAndPRs(bufio.NewScanner(strings.NewReader(item.RefIssue.Body)))
|
||||
for _, pr := range prs {
|
||||
if pr.Org == org && pr.Repo == repo && pr.Num == num {
|
||||
LogDebug("Found PrjGit PR in Timeline:", issue.Index)
|
||||
|
||||
// found prjgit PR in timeline. Return it
|
||||
@@ -88,7 +156,7 @@ func LastPrjGitRefOnTimeline(gitea GiteaPRTimelineFetcher, org, repo string, num
|
||||
return nil, Timeline_RefIssueNotFound
|
||||
}
|
||||
|
||||
func FetchPRSet(user string, gitea GiteaPRTimelineFetcher, org, repo string, num int64, config *AutogitConfig) (*PRSet, error) {
|
||||
func FetchPRSet(user string, gitea GiteaPRTimelineReviewFetcher, org, repo string, num int64, config *AutogitConfig) (*PRSet, error) {
|
||||
var pr *models.PullRequest
|
||||
var err error
|
||||
|
||||
@@ -98,7 +166,7 @@ func FetchPRSet(user string, gitea GiteaPRTimelineFetcher, org, repo string, num
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if pr, err = LastPrjGitRefOnTimeline(gitea, org, repo, num, prjGitOrg, prjGitRepo); err != nil && err != Timeline_RefIssueNotFound {
|
||||
if pr, err = LastPrjGitRefOnTimeline(user, gitea, org, repo, num, config); err != nil && err != Timeline_RefIssueNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -114,6 +182,15 @@ func FetchPRSet(user string, gitea GiteaPRTimelineFetcher, org, repo string, num
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pr := range prs {
|
||||
org, repo, idx := pr.PRComponents()
|
||||
reviews, err := FetchGiteaReviews(gitea, org, repo, idx)
|
||||
if err != nil {
|
||||
LogError("Error fetching reviews for", PRtoString(pr.PR), ":", err)
|
||||
}
|
||||
pr.Reviews = reviews
|
||||
}
|
||||
|
||||
return &PRSet{
|
||||
PRs: prs,
|
||||
Config: config,
|
||||
@@ -121,6 +198,12 @@ func FetchPRSet(user string, gitea GiteaPRTimelineFetcher, org, repo string, num
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (prset *PRSet) RemoveReviewers(gitea GiteaUnreviewTimelineFetcher, reviewers []string) {
|
||||
for _, prinfo := range prset.PRs {
|
||||
prinfo.RemoveReviewers(gitea, reviewers, prset.BotUser)
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *PRSet) Find(pr *models.PullRequest) (*PRInfo, bool) {
|
||||
for _, p := range rs.PRs {
|
||||
if p.PR.Base.RepoID == pr.Base.RepoID &&
|
||||
@@ -206,67 +289,144 @@ next_rs:
|
||||
}
|
||||
|
||||
for _, pr := range prjpr_set {
|
||||
if prinfo.PR.Base.Repo.Owner.UserName == pr.Org && prinfo.PR.Base.Repo.Name == pr.Repo && prinfo.PR.Index == pr.Num {
|
||||
if strings.EqualFold(prinfo.PR.Base.Repo.Owner.UserName, pr.Org) && strings.EqualFold(prinfo.PR.Base.Repo.Name, pr.Repo) && prinfo.PR.Index == pr.Num {
|
||||
continue next_rs
|
||||
}
|
||||
}
|
||||
LogDebug(" PR: ", PRtoString(prinfo.PR), "not found in project git PRSet")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (rs *PRSet) AssignReviewers(gitea GiteaReviewFetcherAndRequester, maintainers MaintainershipData) error {
|
||||
func (rs *PRSet) FindMissingAndExtraReviewers(maintainers MaintainershipData, idx int) (missing, extra []string) {
|
||||
configReviewers := ParseReviewers(rs.Config.Reviewers)
|
||||
|
||||
for _, pr := range rs.PRs {
|
||||
reviewers := []string{}
|
||||
// remove reviewers that were already requested and are not stale
|
||||
prjMaintainers := maintainers.ListProjectMaintainers(nil)
|
||||
LogDebug("project maintainers:", prjMaintainers)
|
||||
|
||||
if rs.IsPrjGitPR(pr.PR) {
|
||||
reviewers = slices.Concat(configReviewers.Prj, configReviewers.PrjOptional)
|
||||
LogDebug("PrjGit submitter:", pr.PR.User.UserName)
|
||||
if len(rs.PRs) == 1 {
|
||||
reviewers = slices.Concat(reviewers, maintainers.ListProjectMaintainers())
|
||||
}
|
||||
pr := rs.PRs[idx]
|
||||
if rs.IsPrjGitPR(pr.PR) {
|
||||
missing = slices.Concat(configReviewers.Prj, configReviewers.PrjOptional)
|
||||
if rs.HasAutoStaging {
|
||||
missing = append(missing, Bot_BuildReview)
|
||||
}
|
||||
LogDebug("PrjGit submitter:", pr.PR.User.UserName)
|
||||
// only need project maintainer reviews if:
|
||||
// * not created by a bot and has other PRs, or
|
||||
// * not created by maintainer
|
||||
noReviewPRCreators := prjMaintainers
|
||||
if len(rs.PRs) > 1 {
|
||||
noReviewPRCreators = append(noReviewPRCreators, rs.BotUser)
|
||||
}
|
||||
if slices.Contains(noReviewPRCreators, pr.PR.User.UserName) || pr.Reviews.IsReviewedByOneOf(prjMaintainers...) {
|
||||
LogDebug("Project already reviewed by a project maintainer, remove rest")
|
||||
// do not remove reviewers if they are also maintainers
|
||||
prjMaintainers = slices.DeleteFunc(prjMaintainers, func(m string) bool { return slices.Contains(missing, m) })
|
||||
extra = slices.Concat(prjMaintainers, []string{rs.BotUser})
|
||||
} else {
|
||||
pkg := pr.PR.Base.Repo.Name
|
||||
reviewers = slices.Concat(configReviewers.Pkg, maintainers.ListProjectMaintainers(), maintainers.ListPackageMaintainers(pkg), configReviewers.PkgOptional)
|
||||
}
|
||||
|
||||
slices.Sort(reviewers)
|
||||
reviewers = slices.Compact(reviewers)
|
||||
|
||||
// submitters do not need to review their own work
|
||||
if idx := slices.Index(reviewers, pr.PR.User.UserName); idx != -1 {
|
||||
reviewers = slices.Delete(reviewers, idx, idx+1)
|
||||
}
|
||||
|
||||
LogDebug("PR: ", pr.PR.Base.Repo.Name, pr.PR.Index)
|
||||
LogDebug("reviewers for PR:", reviewers)
|
||||
|
||||
// remove reviewers that were already requested and are not stale
|
||||
reviews, err := FetchGiteaReviews(gitea, reviewers, pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index)
|
||||
if err != nil {
|
||||
LogError("Error fetching reviews:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for idx := 0; idx < len(reviewers); {
|
||||
user := reviewers[idx]
|
||||
if reviews.HasPendingReviewBy(user) || reviews.IsReviewedBy(user) {
|
||||
reviewers = slices.Delete(reviewers, idx, idx+1)
|
||||
LogDebug("removing reviewer:", user)
|
||||
// if bot not created PrjGit or prj maintainer, we need to add project reviewers here
|
||||
if slices.Contains(noReviewPRCreators, pr.PR.User.UserName) {
|
||||
LogDebug("No need for project maintainers")
|
||||
extra = slices.Concat(prjMaintainers, []string{rs.BotUser})
|
||||
} else {
|
||||
idx++
|
||||
LogDebug("Adding prjMaintainers to PrjGit")
|
||||
missing = append(missing, prjMaintainers...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pkg := pr.PR.Base.Repo.Name
|
||||
pkgMaintainers := maintainers.ListPackageMaintainers(pkg, nil)
|
||||
Maintainers := slices.Concat(prjMaintainers, pkgMaintainers)
|
||||
noReviewPkgPRCreators := pkgMaintainers
|
||||
|
||||
// get maintainers associated with the PR too
|
||||
if len(reviewers) > 0 {
|
||||
LogDebug("Requesting reviews from:", reviewers)
|
||||
LogDebug("packakge maintainers:", Maintainers)
|
||||
|
||||
missing = slices.Concat(configReviewers.Pkg, configReviewers.PkgOptional)
|
||||
if slices.Contains(noReviewPkgPRCreators, pr.PR.User.UserName) || pr.Reviews.IsReviewedByOneOf(Maintainers...) {
|
||||
// submitter is maintainer or already reviewed
|
||||
LogDebug("Package reviewed by maintainer (or subitter is maintainer), remove the rest of them")
|
||||
// do not remove reviewers if they are also maintainers
|
||||
Maintainers = slices.DeleteFunc(Maintainers, func(m string) bool { return slices.Contains(missing, m) })
|
||||
extra = slices.Concat(Maintainers, []string{rs.BotUser})
|
||||
} else {
|
||||
// maintainer review is missing
|
||||
LogDebug("Adding package maintainers to package git")
|
||||
missing = append(missing, pkgMaintainers...)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(missing)
|
||||
missing = slices.Compact(missing)
|
||||
|
||||
slices.Sort(extra)
|
||||
extra = slices.Compact(extra)
|
||||
|
||||
// submitters cannot review their own work
|
||||
if idx := slices.Index(missing, pr.PR.User.UserName); idx != -1 {
|
||||
missing = slices.Delete(missing, idx, idx+1)
|
||||
}
|
||||
|
||||
LogDebug("PR: ", PRtoString(pr.PR))
|
||||
LogDebug(" preliminary add reviewers for PR:", missing)
|
||||
LogDebug(" preliminary rm reviewers for PR:", extra)
|
||||
|
||||
// remove missing reviewers that are already done or already pending
|
||||
for idx := 0; idx < len(missing); {
|
||||
user := missing[idx]
|
||||
if pr.Reviews.HasPendingReviewBy(user) || pr.Reviews.IsReviewedBy(user) {
|
||||
missing = slices.Delete(missing, idx, idx+1)
|
||||
LogDebug(" removing done/pending reviewer:", user)
|
||||
} else {
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
// remove extra reviews that are actually only pending, and only pending by us
|
||||
for idx := 0; idx < len(extra); {
|
||||
user := extra[idx]
|
||||
rr := pr.Reviews.FindReviewRequester(user)
|
||||
if rr != nil && rr.User.UserName == rs.BotUser && pr.Reviews.HasPendingReviewBy(user) {
|
||||
// good to remove this review
|
||||
idx++
|
||||
} else {
|
||||
// this review should not be considered as extra by us
|
||||
LogDebug(" - cannot find? to remove", user)
|
||||
if rr != nil {
|
||||
LogDebug(" ", rr.User.UserName, "vs.", rs.BotUser, pr.Reviews.HasPendingReviewBy(user))
|
||||
}
|
||||
extra = slices.Delete(extra, idx, idx+1)
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug(" add reviewers for PR:", missing)
|
||||
LogDebug(" rm reviewers for PR:", extra)
|
||||
|
||||
return missing, extra
|
||||
}
|
||||
|
||||
func (rs *PRSet) AssignReviewers(gitea GiteaReviewFetcherAndRequesterAndUnrequester, maintainers MaintainershipData) error {
|
||||
for idx, pr := range rs.PRs {
|
||||
missingReviewers, extraReviewers := rs.FindMissingAndExtraReviewers(maintainers, idx)
|
||||
|
||||
if len(missingReviewers) > 0 {
|
||||
LogDebug(" Requesting reviews from:", missingReviewers)
|
||||
if !IsDryRun {
|
||||
for _, r := range reviewers {
|
||||
for _, r := range missingReviewers {
|
||||
if _, err := gitea.RequestReviews(pr.PR, r); err != nil {
|
||||
LogError("Cannot create reviews on", fmt.Sprintf("%s/%s#%d for [%s]", pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index, strings.Join(reviewers, ", ")), err)
|
||||
LogError("Cannot create reviews on", PRtoString(pr.PR), "for user:", r, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(extraReviewers) > 0 {
|
||||
LogDebug(" UnRequesting reviews from:", extraReviewers)
|
||||
if !IsDryRun {
|
||||
for _, r := range extraReviewers {
|
||||
org, repo, idx := pr.PRComponents()
|
||||
if err := gitea.UnrequestReview(org, repo, idx, r); err != nil {
|
||||
LogError("Cannot unrequest reviews on", PRtoString(pr.PR), "for user:", r, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,21 +435,29 @@ func (rs *PRSet) AssignReviewers(gitea GiteaReviewFetcherAndRequester, maintaine
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *PRSet) RemoveClosedPRs() {
|
||||
rs.PRs = slices.DeleteFunc(rs.PRs, func(pr *PRInfo) bool {
|
||||
return pr.PR.State != "open"
|
||||
})
|
||||
}
|
||||
|
||||
func (rs *PRSet) IsApproved(gitea GiteaPRChecker, maintainers MaintainershipData) bool {
|
||||
configReviewers := ParseReviewers(rs.Config.Reviewers)
|
||||
|
||||
is_manually_reviewed_ok := false
|
||||
|
||||
if need_manual_review := rs.Config.ManualMergeOnly || rs.Config.ManualMergeProject; need_manual_review {
|
||||
// Groups are expanded here because any group member can issue "merge ok" to the BotUser
|
||||
groups := rs.Config.ReviewGroups
|
||||
prjgit, err := rs.GetPrjGitPR()
|
||||
if err == nil && prjgit != nil {
|
||||
reviewers := slices.Concat(configReviewers.Prj, maintainers.ListProjectMaintainers())
|
||||
reviewers := slices.Concat(configReviewers.Prj, maintainers.ListProjectMaintainers(groups))
|
||||
LogDebug("Fetching reviews for", prjgit.PR.Base.Repo.Owner.UserName, prjgit.PR.Base.Repo.Name, prjgit.PR.Index)
|
||||
r, err := FetchGiteaReviews(gitea, reviewers, prjgit.PR.Base.Repo.Owner.UserName, prjgit.PR.Base.Repo.Name, prjgit.PR.Index)
|
||||
r, err := FetchGiteaReviews(gitea, prjgit.PR.Base.Repo.Owner.UserName, prjgit.PR.Base.Repo.Name, prjgit.PR.Index)
|
||||
if err != nil {
|
||||
LogError("Cannot fetch gita reaviews for PR:", err)
|
||||
return false
|
||||
}
|
||||
r.RequestedReviewers = reviewers
|
||||
prjgit.Reviews = r
|
||||
if prjgit.Reviews.IsManualMergeOK() {
|
||||
is_manually_reviewed_ok = true
|
||||
@@ -303,13 +471,14 @@ func (rs *PRSet) IsApproved(gitea GiteaPRChecker, maintainers MaintainershipData
|
||||
}
|
||||
|
||||
pkg := pr.PR.Base.Repo.Name
|
||||
reviewers := slices.Concat(configReviewers.Pkg, maintainers.ListPackageMaintainers(pkg))
|
||||
reviewers := slices.Concat(configReviewers.Pkg, maintainers.ListPackageMaintainers(pkg, groups))
|
||||
LogDebug("Fetching reviews for", pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index)
|
||||
r, err := FetchGiteaReviews(gitea, reviewers, pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index)
|
||||
r, err := FetchGiteaReviews(gitea, pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index)
|
||||
if err != nil {
|
||||
LogError("Cannot fetch gita reaviews for PR:", err)
|
||||
return false
|
||||
}
|
||||
r.RequestedReviewers = reviewers
|
||||
pr.Reviews = r
|
||||
if !pr.Reviews.IsManualMergeOK() {
|
||||
LogInfo("Not approved manual merge. PR:", pr.PR.URL)
|
||||
@@ -331,6 +500,9 @@ func (rs *PRSet) IsApproved(gitea GiteaPRChecker, maintainers MaintainershipData
|
||||
var pkg string
|
||||
if rs.IsPrjGitPR(pr.PR) {
|
||||
reviewers = configReviewers.Prj
|
||||
if rs.HasAutoStaging {
|
||||
reviewers = append(reviewers, Bot_BuildReview)
|
||||
}
|
||||
pkg = ""
|
||||
} else {
|
||||
reviewers = configReviewers.Pkg
|
||||
@@ -342,20 +514,25 @@ func (rs *PRSet) IsApproved(gitea GiteaPRChecker, maintainers MaintainershipData
|
||||
return false
|
||||
}
|
||||
|
||||
r, err := FetchGiteaReviews(gitea, reviewers, pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index)
|
||||
r, err := FetchGiteaReviews(gitea, pr.PR.Base.Repo.Owner.UserName, pr.PR.Base.Repo.Name, pr.PR.Index)
|
||||
if err != nil {
|
||||
LogError("Cannot fetch gita reaviews for PR:", err)
|
||||
LogError("Cannot fetch gitea reaviews for PR:", err)
|
||||
return false
|
||||
}
|
||||
r.RequestedReviewers = reviewers
|
||||
|
||||
is_manually_reviewed_ok = r.IsApproved()
|
||||
LogDebug(pr.PR.Base.Repo.Name, is_manually_reviewed_ok)
|
||||
LogDebug("PR to", pr.PR.Base.Repo.Name, "reviewed?", is_manually_reviewed_ok)
|
||||
if !is_manually_reviewed_ok {
|
||||
if GetLoggingLevel() > LogLevelInfo {
|
||||
LogDebug("missing reviewers:", r.MissingReviews())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if need_maintainer_review := !rs.IsPrjGitPR(pr.PR) || pr.PR.User.UserName != rs.BotUser; need_maintainer_review {
|
||||
if is_manually_reviewed_ok = maintainers.IsApproved(pkg, r.reviews, pr.PR.User.UserName); !is_manually_reviewed_ok {
|
||||
// Do not expand groups here, as the group-review-bot will ACK if group has reviewed.
|
||||
if is_manually_reviewed_ok = maintainers.IsApproved(pkg, r.Reviews, pr.PR.User.UserName, nil); !is_manually_reviewed_ok {
|
||||
LogDebug(" not approved?", pkg)
|
||||
return false
|
||||
}
|
||||
@@ -373,7 +550,8 @@ func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error {
|
||||
}
|
||||
prjgit := prjgit_info.PR
|
||||
|
||||
remote, err := git.GitClone(DefaultGitPrj, rs.Config.Branch, prjgit.Base.Repo.SSHURL)
|
||||
_, _, prjgitBranch := rs.Config.GetPrjGit()
|
||||
remote, err := git.GitClone(DefaultGitPrj, prjgitBranch, prjgit.Base.Repo.SSHURL)
|
||||
PanicOnError(err)
|
||||
git.GitExecOrPanic(DefaultGitPrj, "fetch", remote, prjgit.Head.Sha)
|
||||
|
||||
@@ -390,7 +568,7 @@ func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error {
|
||||
panic("FIXME")
|
||||
}
|
||||
*/
|
||||
msg := fmt.Sprintf("Merging\n\nPR: %s/%s#%d", prjgit.Base.Repo.Owner.UserName, prjgit.Base.Repo.Name, prjgit.Index)
|
||||
msg := fmt.Sprintf("Merging\n\nPR: %s/%s!%d", prjgit.Base.Repo.Owner.UserName, prjgit.Base.Repo.Name, prjgit.Index)
|
||||
|
||||
err = git.GitExec(DefaultGitPrj, "merge", "--no-ff", "-m", msg, prjgit.Head.Sha)
|
||||
if err != nil {
|
||||
@@ -402,6 +580,7 @@ func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error {
|
||||
// we can only resolve conflicts with .gitmodules
|
||||
for _, s := range status {
|
||||
if s.Status == GitStatus_Unmerged {
|
||||
panic("Can't handle conflicts yet")
|
||||
if s.Path != ".gitmodules" {
|
||||
return err
|
||||
}
|
||||
@@ -492,7 +671,15 @@ func (rs *PRSet) Merge(gitea GiteaReviewUnrequester, git Git) error {
|
||||
if rs.IsPrjGitPR(prinfo.PR) {
|
||||
continue
|
||||
}
|
||||
prinfo.RemoteName, err = git.GitClone(repo.Name, rs.Config.Branch, repo.SSHURL)
|
||||
br := rs.Config.Branch
|
||||
if len(br) == 0 {
|
||||
// if branch is unspecified, take it from the PR as it
|
||||
// matches default branch already
|
||||
br = prinfo.PR.Base.Name
|
||||
} else if br != prinfo.PR.Base.Name {
|
||||
panic(prinfo.PR.Base.Name + " is expected to match " + br)
|
||||
}
|
||||
prinfo.RemoteName, err = git.GitClone(repo.Name, br, repo.SSHURL)
|
||||
PanicOnError(err)
|
||||
git.GitExecOrPanic(repo.Name, "fetch", prinfo.RemoteName, head.Sha)
|
||||
git.GitExecOrPanic(repo.Name, "merge", "--ff", head.Sha)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var UnknownParser error = errors.New("Cannot parse path")
|
||||
|
||||
type PRConflictResolver interface {
|
||||
/*
|
||||
stage_content -> { merge_base (stage1), head (stage2), merge_head (stage3) }
|
||||
*/
|
||||
Resolve(path string, stage_contents [3]string) error
|
||||
}
|
||||
|
||||
var resolvers []PRConflictResolver = []PRConflictResolver{
|
||||
&submodule_conflict_resolver{},
|
||||
}
|
||||
|
||||
func ResolveMergeConflict(path string, file_contents [3]string) error {
|
||||
for _, r := range resolvers {
|
||||
if err := r.Resolve(path, file_contents); err != UnknownParser {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return UnknownParser
|
||||
}
|
||||
|
||||
type submodule_conflict_resolver struct{}
|
||||
|
||||
func (*submodule_conflict_resolver) Resolve(path string, stage [3]string) error {
|
||||
if path != ".gitmodules" {
|
||||
return UnknownParser
|
||||
}
|
||||
return UnknownParser
|
||||
}
|
||||
|
||||
type changes_file_resolver struct{}
|
||||
|
||||
func (*changes_file_resolver) Resolve(path string, stage [3]string) error {
|
||||
if !strings.HasSuffix(path, ".changes") {
|
||||
return UnknownParser
|
||||
}
|
||||
|
||||
return UnknownParser
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package common_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func ResolveSubmoduleConflicts(t *testing.T) {
|
||||
}
|
||||
|
||||
func ResolveChangesFileConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package common_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
@@ -15,22 +14,23 @@ import (
|
||||
"src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
mock_common "src.opensuse.org/autogits/common/mock"
|
||||
)
|
||||
/*
|
||||
func TestCockpit(t *testing.T) {
|
||||
common.SetLoggingLevel(common.LogLevelDebug)
|
||||
gitea := common.AllocateGiteaTransport("https://src.opensuse.org")
|
||||
tl, err := gitea.GetTimeline("cockpit", "cockpit", 29)
|
||||
if err != nil {
|
||||
t.Fatal("Fail to timeline", err)
|
||||
}
|
||||
t.Log(tl)
|
||||
r, err := common.FetchGiteaReviews(gitea, []string{}, "cockpit", "cockpit", 29)
|
||||
if err != nil {
|
||||
t.Fatal("Error:", err)
|
||||
}
|
||||
|
||||
t.Error(r)
|
||||
}
|
||||
/*
|
||||
func TestCockpit(t *testing.T) {
|
||||
common.SetLoggingLevel(common.LogLevelDebug)
|
||||
gitea := common.AllocateGiteaTransport("https://src.opensuse.org")
|
||||
tl, err := gitea.GetTimeline("cockpit", "cockpit", 29)
|
||||
if err != nil {
|
||||
t.Fatal("Fail to timeline", err)
|
||||
}
|
||||
t.Log(tl)
|
||||
r, err := common.FetchGiteaReviews(gitea, []string{}, "cockpit", "cockpit", 29)
|
||||
if err != nil {
|
||||
t.Fatal("Error:", err)
|
||||
}
|
||||
|
||||
t.Error(r)
|
||||
}
|
||||
*/
|
||||
func reviewsToTimeline(reviews []*models.PullReview) []*models.TimelineComment {
|
||||
timeline := make([]*models.TimelineComment, len(reviews))
|
||||
@@ -48,11 +48,13 @@ func reviewsToTimeline(reviews []*models.PullReview) []*models.TimelineComment {
|
||||
}
|
||||
|
||||
func TestPR(t *testing.T) {
|
||||
return
|
||||
|
||||
baseConfig := common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2"},
|
||||
Branch: "branch",
|
||||
Organization: "foo",
|
||||
GitProjectName: "barPrj",
|
||||
GitProjectName: "foo/barPrj#master",
|
||||
}
|
||||
|
||||
type prdata struct {
|
||||
@@ -73,7 +75,7 @@ func TestPR(t *testing.T) {
|
||||
consistentSet bool
|
||||
prjGitPRIndex int
|
||||
|
||||
reviewSetFetcher func(*mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error)
|
||||
reviewSetFetcher func(*mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error)
|
||||
}{
|
||||
{
|
||||
name: "Error fetching PullRequest",
|
||||
@@ -145,7 +147,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: true,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &baseConfig)
|
||||
},
|
||||
},
|
||||
@@ -177,7 +179,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: false,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &baseConfig)
|
||||
},
|
||||
},
|
||||
@@ -205,7 +207,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: false,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2"},
|
||||
Branch: "branch",
|
||||
@@ -239,7 +241,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: true,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2"},
|
||||
Branch: "branch",
|
||||
@@ -273,7 +275,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: true,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2"},
|
||||
Branch: "branch",
|
||||
@@ -309,7 +311,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: false,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2"},
|
||||
Branch: "branch",
|
||||
@@ -344,7 +346,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: true,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2"},
|
||||
Branch: "branch",
|
||||
@@ -386,7 +388,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: true,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2"},
|
||||
Branch: "branch",
|
||||
@@ -428,7 +430,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: false,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2"},
|
||||
Branch: "branch",
|
||||
@@ -471,7 +473,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: false,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
return common.FetchPRSet("test", mock, "foo", "barPrj", 42, &common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2"},
|
||||
Branch: "branch",
|
||||
@@ -498,7 +500,7 @@ func TestPR(t *testing.T) {
|
||||
prjGitPRIndex: 0,
|
||||
consistentSet: true,
|
||||
reviewed: true,
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineFetcher) (*common.PRSet, error) {
|
||||
reviewSetFetcher: func(mock *mock_common.MockGiteaPRTimelineReviewFetcher) (*common.PRSet, error) {
|
||||
config := common.AutogitConfig{
|
||||
Reviewers: []string{"+super1", "*super2", "m1", "-m2", "~*bot"},
|
||||
Branch: "branch",
|
||||
@@ -513,7 +515,7 @@ func TestPR(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctl := gomock.NewController(t)
|
||||
pr_mock := mock_common.NewMockGiteaPRTimelineFetcher(ctl)
|
||||
pr_mock := mock_common.NewMockGiteaPRTimelineReviewFetcher(ctl)
|
||||
review_mock := mock_common.NewMockGiteaPRChecker(ctl)
|
||||
// reviewer_mock := mock_common.NewMockGiteaReviewRequester(ctl)
|
||||
|
||||
@@ -606,9 +608,9 @@ func TestPR(t *testing.T) {
|
||||
*/
|
||||
|
||||
maintainers := mock_common.NewMockMaintainershipData(ctl)
|
||||
maintainers.EXPECT().ListPackageMaintainers(gomock.Any()).Return([]string{}).AnyTimes()
|
||||
maintainers.EXPECT().ListProjectMaintainers().Return([]string{}).AnyTimes()
|
||||
maintainers.EXPECT().IsApproved(gomock.Any(), gomock.Any(), gomock.Any()).Return(true).AnyTimes()
|
||||
maintainers.EXPECT().ListPackageMaintainers(gomock.Any(), gomock.Any()).Return([]string{}).AnyTimes()
|
||||
maintainers.EXPECT().ListProjectMaintainers(gomock.Any()).Return([]string{}).AnyTimes()
|
||||
maintainers.EXPECT().IsApproved(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(true).AnyTimes()
|
||||
|
||||
if isApproved := res.IsApproved(review_mock, maintainers); isApproved != test.reviewed {
|
||||
t.Error("expected reviewed to be NOT", isApproved)
|
||||
@@ -617,288 +619,514 @@ func TestPR(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRAssignReviewers(t *testing.T) {
|
||||
|
||||
func TestFindMissingAndExtraReviewers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config common.AutogitConfig
|
||||
reviewers []struct {
|
||||
org, repo string
|
||||
num int64
|
||||
reviewer string
|
||||
}
|
||||
|
||||
pkgReviews []*models.PullReview
|
||||
pkgTimeline []*models.TimelineComment
|
||||
prjReviews []*models.PullReview
|
||||
prjTimeline []*models.TimelineComment
|
||||
prset *common.PRSet
|
||||
maintainers common.MaintainershipData
|
||||
|
||||
expectedReviewerCall [2][]string
|
||||
noAutoStaging bool
|
||||
|
||||
expected_missing_reviewers [][]string
|
||||
expected_extra_reviewers [][]string
|
||||
}{
|
||||
{
|
||||
name: "No reviewers",
|
||||
config: common.AutogitConfig{
|
||||
GitProjectName: "repo",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{},
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "foo"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{},
|
||||
},
|
||||
},
|
||||
expectedReviewerCall: [2][]string{{"autogits_obs_staging_bot"}, {"prjmaintainer", "pkgmaintainer"}},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{}},
|
||||
},
|
||||
{
|
||||
name: "One project reviewer only",
|
||||
config: common.AutogitConfig{
|
||||
GitProjectName: "repo",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1"},
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "foo"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "foo"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prg"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1"},
|
||||
},
|
||||
},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{}},
|
||||
|
||||
expected_missing_reviewers: [][]string{
|
||||
[]string{},
|
||||
[]string{"autogits_obs_staging_bot", "user1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "One project reviewer only and no auto staging",
|
||||
noAutoStaging: true,
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "foo"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "foo"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prg"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1"},
|
||||
},
|
||||
},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{}},
|
||||
|
||||
expected_missing_reviewers: [][]string{
|
||||
nil,
|
||||
{"user1"},
|
||||
},
|
||||
expectedReviewerCall: [2][]string{{"user1", "autogits_obs_staging_bot"}, {"prjmaintainer", "pkgmaintainer"}},
|
||||
},
|
||||
{
|
||||
name: "One project reviewer and one pkg reviewer only",
|
||||
config: common.AutogitConfig{
|
||||
GitProjectName: "repo",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "user2"},
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "foo"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "foo"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prg"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "user2"},
|
||||
},
|
||||
},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{}},
|
||||
|
||||
expected_missing_reviewers: [][]string{
|
||||
[]string{"user2"},
|
||||
[]string{"autogits_obs_staging_bot", "user1"},
|
||||
},
|
||||
expectedReviewerCall: [2][]string{{"user1", "autogits_obs_staging_bot"}, {"user2", "prjmaintainer", "pkgmaintainer"}},
|
||||
},
|
||||
{
|
||||
name: "No need to get reviews of submitter",
|
||||
config: common.AutogitConfig{
|
||||
GitProjectName: "repo",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "submitter"},
|
||||
name: "No need to get reviews of submitter reviewer",
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "submitter"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{
|
||||
Reviews: []*models.PullReview{{State: common.ReviewStateRequestReview, User: &models.User{UserName: "m1"}}},
|
||||
RequestedReviewers: []string{"m1"},
|
||||
FullTimeline: []*models.TimelineComment{
|
||||
{User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "m1"}, Type: common.TimelineCommentType_ReviewRequested},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "foo"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prg"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "submitter"},
|
||||
},
|
||||
BotUser: "bot",
|
||||
},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{"pkg": []string{"m1", "submitter"}}},
|
||||
|
||||
expected_missing_reviewers: [][]string{
|
||||
nil,
|
||||
{"autogits_obs_staging_bot", "user1"},
|
||||
},
|
||||
expected_extra_reviewers: [][]string{
|
||||
{"m1"},
|
||||
},
|
||||
expectedReviewerCall: [2][]string{{"user1", "autogits_obs_staging_bot"}, {"prjmaintainer", "pkgmaintainer"}},
|
||||
},
|
||||
{
|
||||
name: "Reviews are done",
|
||||
config: common.AutogitConfig{
|
||||
GitProjectName: "repo",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "user2"},
|
||||
},
|
||||
pkgReviews: []*models.PullReview{
|
||||
{
|
||||
State: common.ReviewStateApproved,
|
||||
User: &models.User{UserName: "user2"},
|
||||
name: "No need to get reviews of submitter maintainer",
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "submitter"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "foo"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prg"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
},
|
||||
{
|
||||
State: common.ReviewStateApproved,
|
||||
User: &models.User{UserName: "pkgmaintainer"},
|
||||
},
|
||||
{
|
||||
State: common.ReviewStatePending,
|
||||
User: &models.User{UserName: "prjmaintainer"},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "submitter"},
|
||||
},
|
||||
},
|
||||
prjReviews: []*models.PullReview{
|
||||
{
|
||||
State: common.ReviewStateRequestChanges,
|
||||
User: &models.User{UserName: "user1"},
|
||||
},
|
||||
{
|
||||
State: common.ReviewStateRequestReview,
|
||||
User: &models.User{UserName: "autogits_obs_staging_bot"},
|
||||
},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{"pkg": []string{"submitter"}}},
|
||||
|
||||
expected_missing_reviewers: [][]string{
|
||||
[]string{},
|
||||
[]string{"autogits_obs_staging_bot", "user1"},
|
||||
},
|
||||
expectedReviewerCall: [2][]string{},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Add reviewer if also maintainer where review by maintainer is not needed",
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "submitter"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "bot"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prg"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "submitter", "*reviewer"},
|
||||
},
|
||||
BotUser: "bot",
|
||||
},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{"pkg": []string{"submitter", "reviewer"}, "": []string{"reviewer"}}},
|
||||
|
||||
expected_missing_reviewers: [][]string{
|
||||
[]string{"reviewer"},
|
||||
[]string{"autogits_obs_staging_bot", "reviewer", "user1"},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Dont remove reviewer if also maintainer",
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "submitter"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{
|
||||
Reviews: []*models.PullReview{{State: common.ReviewStateRequestReview, User: &models.User{UserName: "reviewer"}}},
|
||||
RequestedReviewers: []string{"reviewer"},
|
||||
FullTimeline: []*models.TimelineComment{{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "reviewer"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "bot"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prg"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{
|
||||
Reviews: []*models.PullReview{{State: common.ReviewStateRequestReview, User: &models.User{UserName: "reviewer"}}},
|
||||
RequestedReviewers: []string{"reviewer"},
|
||||
FullTimeline: []*models.TimelineComment{{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "reviewer"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "submitter", "*reviewer"},
|
||||
},
|
||||
BotUser: "bot",
|
||||
},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{"pkg": []string{"submitter", "reviewer"}, "": []string{"reviewer"}}},
|
||||
|
||||
expected_missing_reviewers: [][]string{
|
||||
[]string{},
|
||||
[]string{"autogits_obs_staging_bot", "user1"},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Extra project reviewer on the package",
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "submitter"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{
|
||||
Reviews: []*models.PullReview{
|
||||
{State: common.ReviewStateApproved, User: &models.User{UserName: "user2"}},
|
||||
{State: common.ReviewStateApproved, User: &models.User{UserName: "pkgmaintainer"}},
|
||||
{State: common.ReviewStatePending, User: &models.User{UserName: "prjmaintainer"}},
|
||||
},
|
||||
RequestedReviewers: []string{"user2", "pkgmaintainer", "prjmaintainer"},
|
||||
FullTimeline: []*models.TimelineComment{
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "user2"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "pkgmaintainer"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "prjmaintainer"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "bot"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prg"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{
|
||||
Reviews: []*models.PullReview{
|
||||
{State: common.ReviewStateRequestChanges, User: &models.User{UserName: "user1"}},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "autogits_obs_staging_bot"}},
|
||||
},
|
||||
RequestedReviewers: []string{"user1", "autogits_obs_staging_bot"},
|
||||
FullTimeline: []*models.TimelineComment{
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "user1"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "autogits_obs_staging_bot"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "submitter"},
|
||||
},
|
||||
BotUser: "bot",
|
||||
},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{"pkg": []string{"pkgmaintainer"}, "": {"prjmaintainer"}}},
|
||||
|
||||
expected_missing_reviewers: [][]string{},
|
||||
expected_extra_reviewers: [][]string{{"prjmaintainer"}},
|
||||
},
|
||||
{
|
||||
name: "Stale review is not done, re-request it",
|
||||
config: common.AutogitConfig{
|
||||
GitProjectName: "repo",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "user2"},
|
||||
name: "Extra project reviewers on the package and project",
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "submitter"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{
|
||||
Reviews: []*models.PullReview{
|
||||
{State: common.ReviewStateApproved, User: &models.User{UserName: "user2"}},
|
||||
{State: common.ReviewStateApproved, User: &models.User{UserName: "pkgmaintainer"}},
|
||||
{State: common.ReviewStatePending, User: &models.User{UserName: "prjmaintainer"}},
|
||||
{State: common.ReviewStatePending, User: &models.User{UserName: "pkgm1"}},
|
||||
{State: common.ReviewStatePending, User: &models.User{UserName: "pkgm2"}},
|
||||
{State: common.ReviewStatePending, User: &models.User{UserName: "prj1"}},
|
||||
{State: common.ReviewStatePending, User: &models.User{UserName: "prj2"}},
|
||||
{State: common.ReviewStatePending, User: &models.User{UserName: "someother"}},
|
||||
},
|
||||
RequestedReviewers: []string{"user2", "pkgmaintainer", "prjmaintainer", "pkgm1", "pkgm2", "someother", "prj1", "prj2"},
|
||||
FullTimeline: []*models.TimelineComment{
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "pkgmaintainer"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "prjmaintainer"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "prj1"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "prj2"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "pkgm1"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "pkgm2"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "bot"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prg"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{
|
||||
Reviews: []*models.PullReview{
|
||||
{State: common.ReviewStateRequestChanges, User: &models.User{UserName: "user1"}},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "autogits_obs_staging_bot"}},
|
||||
{State: common.ReviewStatePending, User: &models.User{UserName: "prj1"}},
|
||||
{State: common.ReviewStatePending, User: &models.User{UserName: "prj2"}},
|
||||
},
|
||||
RequestedReviewers: []string{"user1", "autogits_obs_staging_bot", "prj1", "prj2"},
|
||||
FullTimeline: []*models.TimelineComment{
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "autogits_obs_staging_bot"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "prj1"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "prj2"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prg/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "submitter"},
|
||||
},
|
||||
BotUser: "bot",
|
||||
},
|
||||
pkgReviews: []*models.PullReview{
|
||||
{
|
||||
State: common.ReviewStateApproved,
|
||||
User: &models.User{UserName: "user2"},
|
||||
},
|
||||
{
|
||||
State: common.ReviewStatePending,
|
||||
User: &models.User{UserName: "prjmaintainer"},
|
||||
},
|
||||
},
|
||||
prjReviews: []*models.PullReview{
|
||||
{
|
||||
State: common.ReviewStateRequestChanges,
|
||||
User: &models.User{UserName: "user1"},
|
||||
Stale: true,
|
||||
},
|
||||
{
|
||||
State: common.ReviewStateRequestReview,
|
||||
Stale: true,
|
||||
User: &models.User{UserName: "autogits_obs_staging_bot"},
|
||||
},
|
||||
},
|
||||
expectedReviewerCall: [2][]string{{"user1", "autogits_obs_staging_bot"}, {"pkgmaintainer"}},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{"pkg": []string{"pkgmaintainer", "pkgm1", "pkgm2"}, "": {"prjmaintainer", "prj1", "prj2"}}},
|
||||
|
||||
expected_missing_reviewers: [][]string{},
|
||||
expected_extra_reviewers: [][]string{{"pkgm1", "pkgm2", "prj1", "prj2", "prjmaintainer"}, {"prj1", "prj2"}},
|
||||
},
|
||||
{
|
||||
name: "Stale optional review is not done, re-request it",
|
||||
config: common.AutogitConfig{
|
||||
GitProjectName: "repo",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "user2", "~bot"},
|
||||
name: "No extra project reviewers on the package and project (all pending)",
|
||||
prset: &common.PRSet{
|
||||
PRs: []*common.PRInfo{
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "submitter"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg", Owner: &models.User{UserName: "org"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{
|
||||
Reviews: []*models.PullReview{
|
||||
{State: common.ReviewStateApproved, User: &models.User{UserName: "user2"}},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "pkgmaintainer"}},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "prjmaintainer"}},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "pkgm1"}},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "prj1"}},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "someother"}},
|
||||
},
|
||||
RequestedReviewers: []string{"user2", "pkgmaintainer", "prjmaintainer", "pkgm1", "someother", "prj1"},
|
||||
FullTimeline: []*models.TimelineComment{
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "pkgm1"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "pkgmaintainer"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "prjmaintainer"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "prj1"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "!bot"}, Assignee: &models.User{UserName: "someother"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
PR: &models.PullRequest{
|
||||
User: &models.User{UserName: "bot"},
|
||||
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "prj"}}},
|
||||
},
|
||||
Reviews: &common.PRReviews{
|
||||
Reviews: []*models.PullReview{
|
||||
{State: common.ReviewStateRequestChanges, User: &models.User{UserName: "user1"}},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "autogits_obs_staging_bot"}},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "prj1"}},
|
||||
},
|
||||
RequestedReviewers: []string{"user1", "autogits_obs_staging_bot", "prj1"},
|
||||
FullTimeline: []*models.TimelineComment{
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "autogits_obs_staging_bot"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "bot"}, Assignee: &models.User{UserName: "prj1"}},
|
||||
{Type: common.TimelineCommentType_ReviewRequested, User: &models.User{UserName: "!bot"}, Assignee: &models.User{UserName: "user1"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &common.AutogitConfig{
|
||||
GitProjectName: "prj/repo#main",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{"-user1", "submitter"},
|
||||
},
|
||||
BotUser: "bot",
|
||||
},
|
||||
pkgReviews: []*models.PullReview{
|
||||
{
|
||||
State: common.ReviewStateApproved,
|
||||
User: &models.User{UserName: "bot"},
|
||||
Stale: true,
|
||||
},
|
||||
{
|
||||
State: common.ReviewStateApproved,
|
||||
User: &models.User{UserName: "user2"},
|
||||
},
|
||||
{
|
||||
State: common.ReviewStatePending,
|
||||
User: &models.User{UserName: "prjmaintainer"},
|
||||
},
|
||||
},
|
||||
prjReviews: []*models.PullReview{
|
||||
{
|
||||
State: common.ReviewStateRequestChanges,
|
||||
User: &models.User{UserName: "user1"},
|
||||
Stale: true,
|
||||
},
|
||||
{
|
||||
State: common.ReviewStateRequestReview,
|
||||
Stale: true,
|
||||
User: &models.User{UserName: "autogits_obs_staging_bot"},
|
||||
},
|
||||
},
|
||||
expectedReviewerCall: [2][]string{{"user1", "autogits_obs_staging_bot"}, {"pkgmaintainer", "bot"}},
|
||||
maintainers: &common.MaintainershipMap{Data: map[string][]string{"pkg": []string{"pkgmaintainer", "pkgm1", "pkgm2"}, "": {"prjmaintainer", "prj1", "prj2"}}},
|
||||
|
||||
expected_missing_reviewers: [][]string{{"pkgm2", "prj2"}},
|
||||
expected_extra_reviewers: [][]string{{}, {"prj1"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctl := gomock.NewController(t)
|
||||
pr_mock := mock_common.NewMockGiteaPRTimelineFetcher(ctl)
|
||||
review_mock := mock_common.NewMockGiteaReviewFetcherAndRequester(ctl)
|
||||
maintainership_mock := mock_common.NewMockMaintainershipData(ctl)
|
||||
test.prset.HasAutoStaging = !test.noAutoStaging
|
||||
for idx, pr := range test.prset.PRs {
|
||||
missing, extra := test.prset.FindMissingAndExtraReviewers(test.maintainers, idx)
|
||||
|
||||
if test.pkgTimeline == nil {
|
||||
test.pkgTimeline = reviewsToTimeline(test.pkgReviews)
|
||||
}
|
||||
if test.prjTimeline == nil {
|
||||
test.prjTimeline = reviewsToTimeline(test.prjReviews)
|
||||
}
|
||||
|
||||
pr_mock.EXPECT().GetPullRequest("other", "pkgrepo", int64(1)).Return(&models.PullRequest{
|
||||
Body: "Some description is here",
|
||||
User: &models.User{UserName: "submitter"},
|
||||
RequestedReviewers: []*models.User{},
|
||||
Base: &models.PRBranchInfo{Repo: &models.Repository{Name: "pkgrepo", Owner: &models.User{UserName: "other"}}},
|
||||
Head: &models.PRBranchInfo{},
|
||||
Index: 1,
|
||||
}, nil)
|
||||
review_mock.EXPECT().GetPullRequestReviews("other", "pkgrepo", int64(1)).Return(test.pkgReviews, nil)
|
||||
review_mock.EXPECT().GetTimeline("other", "pkgrepo", int64(1)).Return(test.pkgTimeline, nil)
|
||||
pr_mock.EXPECT().GetPullRequest("org", "repo", int64(1)).Return(&models.PullRequest{
|
||||
Body: fmt.Sprintf(common.PrPattern, "other", "pkgrepo", 1),
|
||||
User: &models.User{UserName: "bot1"},
|
||||
RequestedReviewers: []*models.User{{UserName: "main_reviewer"}},
|
||||
Base: &models.PRBranchInfo{Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "org"}}},
|
||||
Head: &models.PRBranchInfo{},
|
||||
Index: 42,
|
||||
}, nil)
|
||||
review_mock.EXPECT().GetPullRequestReviews("org", "repo", int64(42)).Return(test.prjReviews, nil)
|
||||
review_mock.EXPECT().GetTimeline("org", "repo", int64(42)).Return(test.prjTimeline, nil)
|
||||
|
||||
maintainership_mock.EXPECT().ListProjectMaintainers().Return([]string{"prjmaintainer"}).AnyTimes()
|
||||
maintainership_mock.EXPECT().ListPackageMaintainers("pkgrepo").Return([]string{"pkgmaintainer"}).AnyTimes()
|
||||
|
||||
prs, _ := common.FetchPRSet("test", pr_mock, "other", "pkgrepo", int64(1), &test.config)
|
||||
if len(prs.PRs) != 2 {
|
||||
t.Fatal("PRs not fetched")
|
||||
}
|
||||
for _, pr := range prs.PRs {
|
||||
r := test.expectedReviewerCall[0]
|
||||
if !prs.IsPrjGitPR(pr.PR) {
|
||||
r = test.expectedReviewerCall[1]
|
||||
// avoid nil dereference below, by adding empty array elements
|
||||
if idx >= len(test.expected_missing_reviewers) {
|
||||
test.expected_missing_reviewers = append(test.expected_missing_reviewers, nil)
|
||||
}
|
||||
slices.Sort(r)
|
||||
for _, reviewer := range r {
|
||||
review_mock.EXPECT().RequestReviews(pr.PR, reviewer).Return(nil, nil)
|
||||
if idx >= len(test.expected_extra_reviewers) {
|
||||
test.expected_extra_reviewers = append(test.expected_extra_reviewers, nil)
|
||||
}
|
||||
|
||||
slices.Sort(test.expected_extra_reviewers[idx])
|
||||
slices.Sort(test.expected_missing_reviewers[idx])
|
||||
if slices.Compare(missing, test.expected_missing_reviewers[idx]) != 0 {
|
||||
t.Error("Expected missing reviewers for", common.PRtoString(pr.PR), ":", test.expected_missing_reviewers[idx], "but have:", missing)
|
||||
}
|
||||
|
||||
if slices.Compare(extra, test.expected_extra_reviewers[idx]) != 0 {
|
||||
t.Error("Expected reviewers to remove for", common.PRtoString(pr.PR), ":", test.expected_extra_reviewers[idx], "but have:", extra)
|
||||
}
|
||||
}
|
||||
prs.AssignReviewers(review_mock, maintainership_mock)
|
||||
})
|
||||
}
|
||||
|
||||
prjgit_tests := []struct {
|
||||
name string
|
||||
config common.AutogitConfig
|
||||
reviewers []struct {
|
||||
org, repo string
|
||||
num int64
|
||||
reviewer string
|
||||
}
|
||||
|
||||
prjReviews []*models.PullReview
|
||||
|
||||
expectedReviewerCall [2][]string
|
||||
}{
|
||||
{
|
||||
name: "PrjMaintainers in prjgit review when not part of pkg set",
|
||||
config: common.AutogitConfig{
|
||||
GitProjectName: "repo",
|
||||
Organization: "org",
|
||||
Branch: "main",
|
||||
Reviewers: []string{},
|
||||
},
|
||||
expectedReviewerCall: [2][]string{{"autogits_obs_staging_bot", "prjmaintainer"}},
|
||||
},
|
||||
}
|
||||
for _, test := range prjgit_tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctl := gomock.NewController(t)
|
||||
pr_mock := mock_common.NewMockGiteaPRTimelineFetcher(ctl)
|
||||
review_mock := mock_common.NewMockGiteaReviewFetcherAndRequester(ctl)
|
||||
maintainership_mock := mock_common.NewMockMaintainershipData(ctl)
|
||||
|
||||
pr_mock.EXPECT().GetPullRequest("org", "repo", int64(1)).Return(&models.PullRequest{
|
||||
Body: "Some description is here",
|
||||
User: &models.User{UserName: "submitter"},
|
||||
RequestedReviewers: []*models.User{},
|
||||
Base: &models.PRBranchInfo{Repo: &models.Repository{Name: "repo", Owner: &models.User{UserName: "org"}}},
|
||||
Head: &models.PRBranchInfo{},
|
||||
Index: 1,
|
||||
}, nil)
|
||||
review_mock.EXPECT().GetPullRequestReviews("org", "repo", int64(1)).Return(test.prjReviews, nil)
|
||||
review_mock.EXPECT().GetTimeline("org", "repo", int64(1)).Return(nil, nil)
|
||||
|
||||
maintainership_mock.EXPECT().ListProjectMaintainers().Return([]string{"prjmaintainer"}).AnyTimes()
|
||||
|
||||
prs, _ := common.FetchPRSet("test", pr_mock, "org", "repo", int64(1), &test.config)
|
||||
if len(prs.PRs) != 1 {
|
||||
t.Fatal("PRs not fetched")
|
||||
}
|
||||
for _, pr := range prs.PRs {
|
||||
r := test.expectedReviewerCall[0]
|
||||
if !prs.IsPrjGitPR(pr.PR) {
|
||||
t.Fatal("only prjgit pr here")
|
||||
}
|
||||
for _, reviewer := range r {
|
||||
review_mock.EXPECT().RequestReviews(pr.PR, reviewer).Return(nil, nil)
|
||||
}
|
||||
}
|
||||
prs.AssignReviewers(review_mock, maintainership_mock)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRMerge(t *testing.T) {
|
||||
t.Skip("FAIL: No PrjGit PR found, missing calls")
|
||||
repoDir := t.TempDir()
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
@@ -923,7 +1151,7 @@ func TestPRMerge(t *testing.T) {
|
||||
|
||||
config := &common.AutogitConfig{
|
||||
Organization: "org",
|
||||
GitProjectName: "prj",
|
||||
GitProjectName: "org/prj#master",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -975,7 +1203,7 @@ func TestPRMerge(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctl := gomock.NewController(t)
|
||||
mock := mock_common.NewMockGiteaPRTimelineFetcher(ctl)
|
||||
mock := mock_common.NewMockGiteaPRTimelineReviewFetcher(ctl)
|
||||
reviewUnrequestMock := mock_common.NewMockGiteaReviewUnrequester(ctl)
|
||||
|
||||
reviewUnrequestMock.EXPECT().UnrequestReview(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
|
||||
@@ -1003,6 +1231,7 @@ func TestPRMerge(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPRChanges(t *testing.T) {
|
||||
t.Skip("FAIL: unexpected calls, missing calls")
|
||||
tests := []struct {
|
||||
name string
|
||||
PRs []*models.PullRequest
|
||||
@@ -1033,7 +1262,7 @@ func TestPRChanges(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctl := gomock.NewController(t)
|
||||
mock_fetcher := mock_common.NewMockGiteaPRTimelineFetcher(ctl)
|
||||
mock_fetcher := mock_common.NewMockGiteaPRTimelineReviewFetcher(ctl)
|
||||
mock_fetcher.EXPECT().GetPullRequest("org", "prjgit", int64(42)).Return(test.PrjPRs, nil)
|
||||
for _, pr := range test.PRs {
|
||||
mock_fetcher.EXPECT().GetPullRequest(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index).Return(pr, nil)
|
||||
|
||||
238
common/rabbitmq.go
Normal file
238
common/rabbitmq.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package common
|
||||
|
||||
/*
|
||||
* This file is part of Autogits.
|
||||
*
|
||||
* Copyright © 2024 SUSE LLC
|
||||
*
|
||||
* Autogits is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 2 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Autogits is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Foobar. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
rabbitmq "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type RabbitConnection struct {
|
||||
RabbitURL *url.URL // amqps://user:password@host/queue
|
||||
|
||||
queueName string
|
||||
ch *rabbitmq.Channel
|
||||
|
||||
topics []string
|
||||
topicSubChanges chan string // +topic = subscribe, -topic = unsubscribe
|
||||
}
|
||||
|
||||
type RabbitProcessor interface {
|
||||
GenerateTopics() []string
|
||||
|
||||
Connection() *RabbitConnection
|
||||
ProcessRabbitMessage(msg RabbitMessage) error
|
||||
}
|
||||
|
||||
type RabbitMessage rabbitmq.Delivery
|
||||
|
||||
func (l *RabbitConnection) ProcessTopicChanges() {
|
||||
for {
|
||||
topic, ok := <-l.topicSubChanges
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
LogDebug(" topic change:", topic)
|
||||
switch topic[0] {
|
||||
case '+':
|
||||
if err := l.ch.QueueBind(l.queueName, topic[1:], "pubsub", false, nil); err != nil {
|
||||
LogError(err)
|
||||
}
|
||||
case '-':
|
||||
if err := l.ch.QueueUnbind(l.queueName, topic[1:], "pubsub", nil); err != nil {
|
||||
LogError(err)
|
||||
}
|
||||
default:
|
||||
LogInfo("Ignoring unknown topic change:", topic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RabbitConnection) ProcessRabbitMQ(msgCh chan<- RabbitMessage) error {
|
||||
queueName := l.RabbitURL.Path
|
||||
l.RabbitURL.Path = ""
|
||||
|
||||
if len(queueName) > 0 && queueName[0] == '/' {
|
||||
queueName = queueName[1:]
|
||||
}
|
||||
|
||||
connection, err := rabbitmq.DialTLS(l.RabbitURL.String(), &tls.Config{
|
||||
ServerName: l.RabbitURL.Hostname(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cannot connect to %s . Err: %w", l.RabbitURL.Hostname(), err)
|
||||
}
|
||||
defer connection.Close()
|
||||
|
||||
l.ch, err = connection.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cannot create a channel. Err: %w", err)
|
||||
}
|
||||
defer l.ch.Close()
|
||||
|
||||
if err = l.ch.ExchangeDeclarePassive("pubsub", "topic", true, false, false, false, nil); err != nil {
|
||||
return fmt.Errorf("Cannot find pubsub exchange? Err: %w", err)
|
||||
}
|
||||
|
||||
var q rabbitmq.Queue
|
||||
if len(queueName) == 0 {
|
||||
q, err = l.ch.QueueDeclare("", false, true, true, false, nil)
|
||||
} else {
|
||||
q, err = l.ch.QueueDeclarePassive(queueName, true, false, true, false, nil)
|
||||
if err != nil {
|
||||
LogInfo("queue not found .. trying to create it:", err)
|
||||
if l.ch.IsClosed() {
|
||||
l.ch, err = connection.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Channel cannot be re-opened. Err: %w", err)
|
||||
}
|
||||
}
|
||||
q, err = l.ch.QueueDeclare(queueName, true, false, true, false, nil)
|
||||
|
||||
if err != nil {
|
||||
LogInfo("can't create persistent queue ... falling back to temporaty queue:", err)
|
||||
if l.ch.IsClosed() {
|
||||
l.ch, err = connection.Channel()
|
||||
return fmt.Errorf("Channel cannot be re-opened. Err: %w", err)
|
||||
}
|
||||
q, err = l.ch.QueueDeclare("", false, true, true, false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cannot declare queue. Err: %w", err)
|
||||
}
|
||||
// log.Printf("queue: %s:%d", q.Name, q.Consumers)
|
||||
|
||||
LogDebug(" -- listening to topics:")
|
||||
l.topicSubChanges = make(chan string)
|
||||
defer close(l.topicSubChanges)
|
||||
go l.ProcessTopicChanges()
|
||||
|
||||
for _, topic := range l.topics {
|
||||
l.topicSubChanges <- "+" + topic
|
||||
}
|
||||
|
||||
msgs, err := l.ch.Consume(q.Name, "", true, true, false, false, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Cannot start consumer. Err: %w", err)
|
||||
}
|
||||
// log.Printf("queue: %s:%d", q.Name, q.Consumers)
|
||||
|
||||
for {
|
||||
msg, ok := <-msgs
|
||||
if !ok {
|
||||
return fmt.Errorf("channel/connection closed?\n")
|
||||
}
|
||||
|
||||
msgCh <- RabbitMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RabbitConnection) ConnectAndProcessRabbitMQ(ch chan<- RabbitMessage) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
LogError(r)
|
||||
LogError("'crash' RabbitMQ worker. Recovering... reconnecting...")
|
||||
time.Sleep(5 * time.Second)
|
||||
go l.ConnectAndProcessRabbitMQ(ch)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
err := l.ProcessRabbitMQ(ch)
|
||||
if err != nil {
|
||||
LogError("Error in RabbitMQ connection:", err)
|
||||
LogInfo("Reconnecting in 2 seconds...")
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RabbitConnection) ConnectToRabbitMQ(processor RabbitProcessor) <-chan RabbitMessage {
|
||||
LogInfo("RabbitMQ connection:", l.RabbitURL.String())
|
||||
|
||||
l.RabbitURL.User = url.UserPassword(rabbitUser, rabbitPassword)
|
||||
l.topics = processor.GenerateTopics()
|
||||
|
||||
ch := make(chan RabbitMessage, 100)
|
||||
go l.ConnectAndProcessRabbitMQ(ch)
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (l *RabbitConnection) UpdateTopics(processor RabbitProcessor) {
|
||||
newTopics := processor.GenerateTopics()
|
||||
|
||||
j := 0
|
||||
next_new_topic:
|
||||
for i := 0; i < len(newTopics); i++ {
|
||||
topic := newTopics[i]
|
||||
|
||||
for j < len(l.topics) {
|
||||
cmp := strings.Compare(topic, l.topics[j])
|
||||
|
||||
if cmp == 0 {
|
||||
j++
|
||||
continue next_new_topic
|
||||
}
|
||||
|
||||
if cmp < 0 {
|
||||
l.topicSubChanges <- "+" + topic
|
||||
break
|
||||
}
|
||||
|
||||
l.topicSubChanges <- "-" + l.topics[j]
|
||||
j++
|
||||
}
|
||||
|
||||
if j == len(l.topics) {
|
||||
l.topicSubChanges <- "+" + topic
|
||||
}
|
||||
}
|
||||
|
||||
for j < len(l.topics) {
|
||||
l.topicSubChanges <- "-" + l.topics[j]
|
||||
j++
|
||||
}
|
||||
|
||||
l.topics = newTopics
|
||||
}
|
||||
|
||||
func ProcessRabbitMQEvents(processor RabbitProcessor) error {
|
||||
ch := processor.Connection().ConnectToRabbitMQ(processor)
|
||||
|
||||
for {
|
||||
msg, ok := <-ch
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
LogDebug("event:", msg.RoutingKey)
|
||||
if err := processor.ProcessRabbitMessage(msg); err != nil {
|
||||
LogError("Error processing", msg.RoutingKey, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
129
common/rabbitmq_gitea.go
Normal file
129
common/rabbitmq_gitea.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package common
|
||||
|
||||
/*
|
||||
* This file is part of Autogits.
|
||||
*
|
||||
* Copyright © 2024 SUSE LLC
|
||||
*
|
||||
* Autogits is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 2 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Autogits is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Foobar. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const RequestType_CreateBrachTag = "create"
|
||||
const RequestType_DeleteBranchTag = "delete"
|
||||
const RequestType_Fork = "fork"
|
||||
const RequestType_Issue = "issues"
|
||||
const RequestType_IssueAssign = "issue_assign"
|
||||
const RequestType_IssueComment = "issue_comment"
|
||||
const RequestType_IssueLabel = "issue_label"
|
||||
const RequestType_IssueMilestone = "issue_milestone"
|
||||
const RequestType_Push = "push"
|
||||
const RequestType_Repository = "repository"
|
||||
const RequestType_Release = "release"
|
||||
const RequestType_PR = "pull_request"
|
||||
const RequestType_PRAssign = "pull_request_assign"
|
||||
const RequestType_PRLabel = "pull_request_label"
|
||||
const RequestType_PRComment = "pull_request_comment"
|
||||
const RequestType_PRMilestone = "pull_request_milestone"
|
||||
const RequestType_PRSync = "pull_request_sync"
|
||||
const RequestType_PRReviewAccepted = "pull_request_review_approved"
|
||||
const RequestType_PRReviewRejected = "pull_request_review_rejected"
|
||||
const RequestType_PRReviewRequest = "pull_request_review_request"
|
||||
const RequestType_PRReviewComment = "pull_request_review_comment"
|
||||
const RequestType_Status = "status"
|
||||
const RequestType_Wiki = "wiki"
|
||||
|
||||
type RequestProcessor interface {
|
||||
ProcessFunc(*Request) error
|
||||
}
|
||||
|
||||
type RabbitMQGiteaEventsProcessor struct {
|
||||
Handlers map[string]RequestProcessor
|
||||
Orgs []string
|
||||
|
||||
c *RabbitConnection
|
||||
}
|
||||
|
||||
func (gitea *RabbitMQGiteaEventsProcessor) Connection() *RabbitConnection {
|
||||
if gitea.c == nil {
|
||||
gitea.c = &RabbitConnection{}
|
||||
}
|
||||
return gitea.c
|
||||
}
|
||||
|
||||
func (gitea *RabbitMQGiteaEventsProcessor) GenerateTopics() []string {
|
||||
topics := make([]string, 0, len(gitea.Handlers)*len(gitea.Orgs))
|
||||
scope := "suse"
|
||||
if gitea.c.RabbitURL.Hostname() == "rabbit.opensuse.org" {
|
||||
scope = "opensuse"
|
||||
}
|
||||
|
||||
for _, org := range gitea.Orgs {
|
||||
for requestType, _ := range gitea.Handlers {
|
||||
topics = append(topics, fmt.Sprintf("%s.src.%s.%s.#", scope, org, requestType))
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(topics)
|
||||
return slices.Compact(topics)
|
||||
}
|
||||
|
||||
func (gitea *RabbitMQGiteaEventsProcessor) ProcessRabbitMessage(msg RabbitMessage) error {
|
||||
route := strings.Split(msg.RoutingKey, ".")
|
||||
if len(route) > 3 {
|
||||
reqType := route[3]
|
||||
org := route[2]
|
||||
|
||||
if !slices.Contains(gitea.Orgs, org) {
|
||||
LogInfo("Got event for unhandeled org:", org)
|
||||
return nil
|
||||
}
|
||||
|
||||
LogDebug("org:", org, "type:", reqType)
|
||||
if handler, found := gitea.Handlers[reqType]; found {
|
||||
req, err := ParseRequestJSON(reqType, msg.Body)
|
||||
if err != nil {
|
||||
LogError("Error parsing request JSON:", err)
|
||||
} else {
|
||||
LogDebug("processing req", req.Type)
|
||||
// h.Request = req
|
||||
ProcessEvent(handler, req)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("Invalid routing key: %s", route)
|
||||
}
|
||||
|
||||
func ProcessEvent(f RequestProcessor, request *Request) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
LogError("panic caught")
|
||||
if err, ok := r.(error); !ok {
|
||||
LogError(err)
|
||||
}
|
||||
LogError(string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := f.ProcessFunc(request); err != nil {
|
||||
LogError(err)
|
||||
}
|
||||
}
|
||||
22
common/rabbitmq_obs.go
Normal file
22
common/rabbitmq_obs.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package common
|
||||
|
||||
type RabbitMQObsBuildStatusProcessor struct {
|
||||
c *RabbitConnection
|
||||
}
|
||||
|
||||
func (o *RabbitMQObsBuildStatusProcessor) GenerateTopics() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (o *RabbitMQObsBuildStatusProcessor) Connection() *RabbitConnection {
|
||||
if o.c == nil {
|
||||
o.c = &RabbitConnection{}
|
||||
}
|
||||
|
||||
return o.c
|
||||
}
|
||||
|
||||
func (o *RabbitMQObsBuildStatusProcessor) ProcessRabbitMessage(msg RabbitMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -50,11 +50,13 @@ func TestListenDefinitionsTopicUpdate(t *testing.T) {
|
||||
u, _ := url.Parse("amqps://rabbit.example.com")
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
l := ListenDefinitions{
|
||||
Orgs: test.orgs1,
|
||||
Handlers: make(map[string]RequestProcessor),
|
||||
topicSubChanges: make(chan string, len(test.topicDelta)*10),
|
||||
RabbitURL: u,
|
||||
l := &RabbitMQGiteaEventsProcessor{
|
||||
Orgs: test.orgs1,
|
||||
Handlers: make(map[string]RequestProcessor),
|
||||
c: &RabbitConnection{
|
||||
RabbitURL: u,
|
||||
topicSubChanges: make(chan string, len(test.topicDelta)*10),
|
||||
},
|
||||
}
|
||||
|
||||
slices.Sort(test.topicDelta)
|
||||
@@ -64,11 +66,11 @@ func TestListenDefinitionsTopicUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
changes := []string{}
|
||||
l.UpdateTopics()
|
||||
l.c.UpdateTopics(l)
|
||||
a:
|
||||
for {
|
||||
select {
|
||||
case c := <-l.topicSubChanges:
|
||||
case c := <-l.c.topicSubChanges:
|
||||
changes = append(changes, c)
|
||||
default:
|
||||
changes = []string{}
|
||||
@@ -78,13 +80,13 @@ func TestListenDefinitionsTopicUpdate(t *testing.T) {
|
||||
|
||||
l.Orgs = test.orgs2
|
||||
|
||||
l.UpdateTopics()
|
||||
l.c.UpdateTopics(l)
|
||||
changes = []string{}
|
||||
|
||||
b:
|
||||
for {
|
||||
select {
|
||||
case c := <-l.topicSubChanges:
|
||||
case c := <-l.c.topicSubChanges:
|
||||
changes = append(changes, c)
|
||||
default:
|
||||
slices.Sort(changes)
|
||||
62
common/request_status.go
Normal file
62
common/request_status.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package common
|
||||
|
||||
/*
|
||||
* This file is part of Autogits.
|
||||
*
|
||||
* Copyright © 2024 SUSE LLC
|
||||
*
|
||||
* Autogits is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 2 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Autogits is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Foobar. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
}
|
||||
|
||||
type StatusWebhookEvent struct {
|
||||
Id uint64
|
||||
Context string
|
||||
Description string
|
||||
Sha string
|
||||
State string
|
||||
TargetUrl string
|
||||
|
||||
Commit Commit
|
||||
Repository Repository
|
||||
Sender *User
|
||||
}
|
||||
|
||||
func (s *StatusWebhookEvent) GetAction() string {
|
||||
return s.State
|
||||
}
|
||||
|
||||
func (h *RequestHandler) ParseStatusRequest(data io.Reader) (*StatusWebhookEvent, error) {
|
||||
action := new(StatusWebhookEvent)
|
||||
err := json.NewDecoder(data).Decode(&action)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Got error while parsing: %w", err)
|
||||
}
|
||||
|
||||
h.StdLogger.Printf("Request status for repo: %s#%s\n", action.Repository.Full_Name, action.Sha)
|
||||
h.Request = &Request{
|
||||
Type: RequestType_Status,
|
||||
Data: action,
|
||||
}
|
||||
|
||||
return action, nil
|
||||
}
|
||||
40
common/request_status_test.go
Normal file
40
common/request_status_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
func TestStatusRequestParsing(t *testing.T) {
|
||||
t.Run("parsing repo creation message", func(t *testing.T) {
|
||||
var h common.RequestHandler
|
||||
|
||||
h.StdLogger, h.ErrLogger = common.CreateStdoutLogger(os.Stdout, os.Stdout)
|
||||
json, err := h.ParseStatusRequest(strings.NewReader(requestStatusJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("Can't parse struct: %s", err)
|
||||
}
|
||||
|
||||
if json.GetAction() != "pending" {
|
||||
t.Fatalf("json.action is '%#v'", json)
|
||||
}
|
||||
|
||||
if json.Repository.Full_Name != "autogits/nodejs-common" ||
|
||||
json.Repository.Parent == nil ||
|
||||
json.Repository.Parent.Parent != nil ||
|
||||
len(json.Repository.Ssh_Url) < 10 ||
|
||||
json.Repository.Default_Branch != "factory" ||
|
||||
json.Repository.Object_Format_Name != "sha256" {
|
||||
|
||||
t.Fatalf("invalid repository parse: %#v", json.Repository)
|
||||
}
|
||||
|
||||
if json.Sha != "e637d86cbbdd438edbf60148e28f9d75a74d51b27b01f75610f247cd18394c8e" {
|
||||
t.Fatal("Invalid SHA:", json.Sha)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
17
common/review_group.go
Normal file
17
common/review_group.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
func (group *ReviewGroup) ExpandMaintainers(maintainers []string) []string {
|
||||
idx := slices.Index(maintainers, group.Name)
|
||||
if idx == -1 {
|
||||
return maintainers
|
||||
}
|
||||
|
||||
expandedMaintainers := slices.Replace(maintainers, idx, idx+1, group.Reviewers...)
|
||||
slices.Sort(expandedMaintainers)
|
||||
return slices.Compact(expandedMaintainers)
|
||||
}
|
||||
|
||||
62
common/review_group_test.go
Normal file
62
common/review_group_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
func TestMaintainerGroupReplacer(t *testing.T) {
|
||||
GroupName := "my_group"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reviewers []string
|
||||
group_members []string
|
||||
|
||||
output []string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
name: "group not maintainer",
|
||||
reviewers: []string{"a", "b"},
|
||||
group_members: []string{"g1", "g2"},
|
||||
output: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "group maintainer",
|
||||
reviewers: []string{"b", "my_group"},
|
||||
group_members: []string{"g1", "g2"},
|
||||
output: []string{"b", "g1", "g2"},
|
||||
},
|
||||
{
|
||||
name: "sorted group maintainer",
|
||||
reviewers: []string{"my_group", "b"},
|
||||
group_members: []string{"g1", "g2"},
|
||||
output: []string{"b", "g1", "g2"},
|
||||
},
|
||||
{
|
||||
name: "group maintainer dedup",
|
||||
reviewers: []string{"my_group", "g2", "b"},
|
||||
group_members: []string{"g1", "g2"},
|
||||
output: []string{"b", "g1", "g2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
g := &common.ReviewGroup{
|
||||
Name: GroupName,
|
||||
Reviewers: test.group_members,
|
||||
}
|
||||
|
||||
expandedList := g.ExpandMaintainers(test.reviewers)
|
||||
if slices.Compare(expandedList, test.output) != 0 {
|
||||
t.Error("Expected:", test.output, "but have", expandedList)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
type Reviewers struct {
|
||||
Prj []string
|
||||
Pkg []string
|
||||
@@ -36,10 +32,5 @@ func ParseReviewers(input []string) *Reviewers {
|
||||
*pkg = append(*pkg, reviewer)
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(r.Prj, Bot_BuildReview) {
|
||||
r.Prj = append(r.Prj, Bot_BuildReview)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -21,14 +21,14 @@ func TestReviewers(t *testing.T) {
|
||||
name: "project and package reviewers",
|
||||
input: []string{"1", "2", "3", "*5", "+6", "-7"},
|
||||
|
||||
prj: []string{"5", "7", common.Bot_BuildReview},
|
||||
prj: []string{"5", "7"},
|
||||
pkg: []string{"1", "2", "3", "5", "6"},
|
||||
},
|
||||
{
|
||||
name: "optional project and package reviewers",
|
||||
input: []string{"~1", "2", "3", "~*5", "+6", "-7"},
|
||||
|
||||
prj: []string{"7", common.Bot_BuildReview},
|
||||
prj: []string{"7"},
|
||||
pkg: []string{"2", "3", "6"},
|
||||
prj_optional: []string{"5"},
|
||||
pkg_optional: []string{"1", "5"},
|
||||
|
||||
@@ -9,12 +9,14 @@ import (
|
||||
)
|
||||
|
||||
type PRReviews struct {
|
||||
reviews []*models.PullReview
|
||||
reviewers []string
|
||||
comments []*models.TimelineComment
|
||||
Reviews []*models.PullReview
|
||||
RequestedReviewers []string
|
||||
Comments []*models.TimelineComment
|
||||
|
||||
FullTimeline []*models.TimelineComment
|
||||
}
|
||||
|
||||
func FetchGiteaReviews(rf GiteaReviewTimelineFetcher, reviewers []string, org, repo string, no int64) (*PRReviews, error) {
|
||||
func FetchGiteaReviews(rf GiteaReviewTimelineFetcher, org, repo string, no int64) (*PRReviews, error) {
|
||||
timeline, err := rf.GetTimeline(org, repo, no)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -25,10 +27,14 @@ func FetchGiteaReviews(rf GiteaReviewTimelineFetcher, reviewers []string, org, r
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reviews := make([]*models.PullReview, 0, len(reviewers))
|
||||
reviews := make([]*models.PullReview, 0, 10)
|
||||
needNewReviews := []string{}
|
||||
var comments []*models.TimelineComment
|
||||
|
||||
alreadyHaveUserReview := func(user string) bool {
|
||||
if slices.Contains(needNewReviews, user) {
|
||||
return true
|
||||
}
|
||||
for _, r := range reviews {
|
||||
if r.User != nil && r.User.UserName == user {
|
||||
return true
|
||||
@@ -37,32 +43,40 @@ func FetchGiteaReviews(rf GiteaReviewTimelineFetcher, reviewers []string, org, r
|
||||
return false
|
||||
}
|
||||
|
||||
LogDebug("FetchingGiteaReviews for", org, repo, no)
|
||||
LogDebug("Number of reviews:", len(rawReviews))
|
||||
LogDebug("Number of items in timeline:", len(timeline))
|
||||
|
||||
cutOffIdx := len(timeline)
|
||||
for idx, item := range timeline {
|
||||
if item.Type == TimelineCommentType_Review {
|
||||
if item.Type == TimelineCommentType_Review || item.Type == TimelineCommentType_ReviewRequested {
|
||||
for _, r := range rawReviews {
|
||||
if r.ID == item.ReviewID {
|
||||
if !alreadyHaveUserReview(r.User.UserName) {
|
||||
reviews = append(reviews, r)
|
||||
if item.Type == TimelineCommentType_Review && idx > cutOffIdx {
|
||||
needNewReviews = append(needNewReviews, r.User.UserName)
|
||||
} else {
|
||||
reviews = append(reviews, r)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if item.Type == TimelineCommentType_Comment {
|
||||
} else if item.Type == TimelineCommentType_Comment && cutOffIdx > idx {
|
||||
comments = append(comments, item)
|
||||
} else if item.Type == TimelineCommentType_PushPull {
|
||||
LogDebug("cut-off", item.Created)
|
||||
timeline = timeline[0:idx]
|
||||
break
|
||||
} else if item.Type == TimelineCommentType_PushPull && cutOffIdx == len(timeline) {
|
||||
LogDebug("cut-off", item.Created, "@", idx)
|
||||
cutOffIdx = idx
|
||||
} else {
|
||||
LogDebug("Unhandled timeline type:", item.Type)
|
||||
}
|
||||
}
|
||||
LogDebug("num comments:", len(comments), "reviews:", len(reviews), len(timeline))
|
||||
LogDebug("num comments:", len(comments), "timeline:", len(reviews))
|
||||
|
||||
return &PRReviews{
|
||||
reviews: reviews,
|
||||
reviewers: reviewers,
|
||||
comments: comments,
|
||||
Reviews: reviews,
|
||||
Comments: comments,
|
||||
FullTimeline: timeline,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -81,23 +95,27 @@ func bodyCommandManualMergeOK(body string) bool {
|
||||
}
|
||||
|
||||
func (r *PRReviews) IsManualMergeOK() bool {
|
||||
for _, c := range r.comments {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, c := range r.Comments {
|
||||
if c.Updated != c.Created {
|
||||
continue
|
||||
}
|
||||
LogDebug("comment:", c.User.UserName, c.Body)
|
||||
if slices.Contains(r.reviewers, c.User.UserName) {
|
||||
if slices.Contains(r.RequestedReviewers, c.User.UserName) {
|
||||
if bodyCommandManualMergeOK(c.Body) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range r.reviews {
|
||||
for _, c := range r.Reviews {
|
||||
if c.Updated != c.Submitted {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(r.reviewers, c.User.UserName) {
|
||||
if slices.Contains(r.RequestedReviewers, c.User.UserName) {
|
||||
if bodyCommandManualMergeOK(c.Body) {
|
||||
return true
|
||||
}
|
||||
@@ -108,11 +126,14 @@ func (r *PRReviews) IsManualMergeOK() bool {
|
||||
}
|
||||
|
||||
func (r *PRReviews) IsApproved() bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
goodReview := true
|
||||
|
||||
for _, reviewer := range r.reviewers {
|
||||
for _, reviewer := range r.RequestedReviewers {
|
||||
goodReview = false
|
||||
for _, review := range r.reviews {
|
||||
for _, review := range r.Reviews {
|
||||
if review.User.UserName == reviewer && review.State == ReviewStateApproved && !review.Stale && !review.Dismissed {
|
||||
LogDebug(" -- found review: ", review.User.UserName)
|
||||
goodReview = true
|
||||
@@ -128,45 +149,78 @@ func (r *PRReviews) IsApproved() bool {
|
||||
return goodReview
|
||||
}
|
||||
|
||||
func (r *PRReviews) HasPendingReviewBy(reviewer string) bool {
|
||||
if !slices.Contains(r.reviewers, reviewer) {
|
||||
return false
|
||||
func (r *PRReviews) MissingReviews() []string {
|
||||
missing := []string{}
|
||||
if r == nil {
|
||||
return missing
|
||||
}
|
||||
|
||||
isPending := false
|
||||
for _, r := range r.reviews {
|
||||
if r.User.UserName == reviewer && !r.Stale {
|
||||
switch r.State {
|
||||
case ReviewStateApproved:
|
||||
fallthrough
|
||||
case ReviewStateRequestChanges:
|
||||
return false
|
||||
case ReviewStateRequestReview:
|
||||
fallthrough
|
||||
case ReviewStatePending:
|
||||
isPending = true
|
||||
}
|
||||
for _, reviewer := range r.RequestedReviewers {
|
||||
if !r.IsReviewedBy(reviewer) {
|
||||
missing = append(missing, reviewer)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func (r *PRReviews) FindReviewRequester(reviewer string) *models.TimelineComment {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, r := range r.FullTimeline {
|
||||
if r.Type == TimelineCommentType_ReviewRequested && r.Assignee.UserName == reviewer {
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
return isPending
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PRReviews) IsReviewedBy(reviewer string) bool {
|
||||
if !slices.Contains(r.reviewers, reviewer) {
|
||||
func (r *PRReviews) HasPendingReviewBy(reviewer string) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range r.reviews {
|
||||
if r.User.UserName == reviewer && !r.Stale {
|
||||
for _, r := range r.Reviews {
|
||||
if r.User.UserName == reviewer {
|
||||
switch r.State {
|
||||
case ReviewStateApproved:
|
||||
return true
|
||||
case ReviewStateRequestChanges:
|
||||
case ReviewStateRequestReview, ReviewStatePending:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *PRReviews) IsReviewedBy(reviewer string) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range r.Reviews {
|
||||
if r.User.UserName == reviewer && !r.Stale {
|
||||
switch r.State {
|
||||
case ReviewStateApproved, ReviewStateRequestChanges:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *PRReviews) IsReviewedByOneOf(reviewers ...string) bool {
|
||||
for _, reviewer := range reviewers {
|
||||
if r.IsReviewedBy(reviewer) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -62,11 +62,23 @@ func TestReviews(t *testing.T) {
|
||||
{
|
||||
name: "Two reviewer, one stale and pending",
|
||||
reviews: []*models.PullReview{
|
||||
&models.PullReview{State: common.ReviewStateRequestReview, User: &models.User{UserName: "user1"}, Stale: true},
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "user1"}, Stale: true},
|
||||
},
|
||||
reviewers: []string{"user1", "user2"},
|
||||
isApproved: false,
|
||||
isPendingByTest1: false,
|
||||
isPendingByTest1: true,
|
||||
isReviewedByTest1: false,
|
||||
},
|
||||
{
|
||||
name: "Two reviewer, one stale and pending, other done",
|
||||
reviews: []*models.PullReview{
|
||||
{State: common.ReviewStateRequestReview, User: &models.User{UserName: "user1"}},
|
||||
{State: common.ReviewStateRequestChanges, User: &models.User{UserName: "user1"}},
|
||||
{State: common.ReviewStateApproved, User: &models.User{UserName: "user2"}},
|
||||
},
|
||||
reviewers: []string{"user1", "user2"},
|
||||
isApproved: false,
|
||||
isPendingByTest1: true,
|
||||
isReviewedByTest1: false,
|
||||
},
|
||||
{
|
||||
@@ -139,7 +151,7 @@ func TestReviews(t *testing.T) {
|
||||
rf.EXPECT().GetTimeline("test", "pr", int64(1)).Return(test.timeline, nil)
|
||||
rf.EXPECT().GetPullRequestReviews("test", "pr", int64(1)).Return(test.reviews, test.fetchErr)
|
||||
|
||||
reviews, err := common.FetchGiteaReviews(rf, test.reviewers, "test", "pr", 1)
|
||||
reviews, err := common.FetchGiteaReviews(rf, "test", "pr", 1)
|
||||
|
||||
if test.fetchErr != nil {
|
||||
if err != test.fetchErr {
|
||||
@@ -147,6 +159,7 @@ func TestReviews(t *testing.T) {
|
||||
}
|
||||
return
|
||||
}
|
||||
reviews.RequestedReviewers = test.reviewers
|
||||
|
||||
if r := reviews.IsApproved(); r != test.isApproved {
|
||||
t.Fatal("Unexpected IsReviewed():", r, "vs. expected", test.isApproved)
|
||||
|
||||
@@ -113,6 +113,10 @@ func (s *Submodule) parseKeyValue(line string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Submodule) ManifestSubmodulePath(manifest *Manifest) string {
|
||||
return manifest.SubdirForPackage(s.Path)
|
||||
}
|
||||
|
||||
func ParseSubmodulesFile(reader io.Reader) ([]Submodule, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
git init -q --bare --object-format=sha256
|
||||
git config user.email test@example.com
|
||||
git config user.name Test
|
||||
export GIT_AUTHOR_DATE=2025-10-27T14:20:07+01:00
|
||||
export GIT_COMMITTER_DATE=2025-10-27T14:20:07+01:00
|
||||
|
||||
# 81aba862107f1e2f5312e165453955485f424612f313d6c2fb1b31fef9f82a14
|
||||
blobA=$(echo "help" | git hash-object --stdin -w)
|
||||
|
||||
116
common/utils.go
116
common/utils.go
@@ -27,10 +27,87 @@ import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
)
|
||||
|
||||
type NewRepos struct {
|
||||
Repos []struct {
|
||||
Organization, Repository, Branch string
|
||||
PackageName string
|
||||
}
|
||||
IsMaintainer bool
|
||||
}
|
||||
|
||||
const maintainership_line = "MAINTAINER"
|
||||
|
||||
var true_lines []string = []string{"1", "TRUE", "YES", "OK", "T"}
|
||||
|
||||
func HasSpace(s string) bool {
|
||||
return strings.IndexFunc(s, unicode.IsSpace) >= 0
|
||||
}
|
||||
|
||||
func FindNewReposInIssueBody(body string) *NewRepos {
|
||||
Issues := &NewRepos{}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if ul := strings.ToUpper(line); strings.HasPrefix(ul, "MAINTAINER") {
|
||||
value := ""
|
||||
if idx := strings.IndexRune(ul, ':'); idx > 0 && len(ul) > idx+2 {
|
||||
value = ul[idx+1:]
|
||||
} else if idx := strings.IndexRune(ul, ' '); idx > 0 && len(ul) > idx+2 {
|
||||
value = ul[idx+1:]
|
||||
}
|
||||
|
||||
if slices.Contains(true_lines, strings.TrimSpace(value)) {
|
||||
Issues.IsMaintainer = true
|
||||
}
|
||||
}
|
||||
// line = strings.TrimSpace(line)
|
||||
issue := struct{ Organization, Repository, Branch, PackageName string }{}
|
||||
|
||||
branch := strings.Split(line, "#")
|
||||
repo := strings.Split(branch[0], "/")
|
||||
|
||||
if len(branch) == 2 {
|
||||
issue.Branch = strings.TrimSpace(branch[1])
|
||||
}
|
||||
if len(repo) == 2 {
|
||||
issue.Organization = strings.TrimSpace(repo[0])
|
||||
issue.Repository = strings.TrimSpace(repo[1])
|
||||
issue.PackageName = issue.Repository
|
||||
|
||||
if idx := strings.Index(strings.ToUpper(issue.Branch), " AS "); idx > 0 && len(issue.Branch) > idx+5 {
|
||||
issue.PackageName = strings.TrimSpace(issue.Branch[idx+3:])
|
||||
issue.Branch = strings.TrimSpace(issue.Branch[0:idx])
|
||||
}
|
||||
|
||||
if HasSpace(issue.Organization) || HasSpace(issue.Repository) || HasSpace(issue.PackageName) || HasSpace(issue.Branch) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
Issues.Repos = append(Issues.Repos, issue)
|
||||
//PackageNameIdx := strings.Index(strings.ToUpper(line), " AS ")
|
||||
//words := strings.Split(line)
|
||||
}
|
||||
|
||||
if len(Issues.Repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
return Issues
|
||||
}
|
||||
|
||||
func IssueToString(issue *models.Issue) string {
|
||||
if issue == nil {
|
||||
return "(nil)"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s#%d", issue.Repository.Owner, issue.Repository.Name, issue.Index)
|
||||
}
|
||||
|
||||
func SplitLines(str string) []string {
|
||||
return SplitStringNoEmpty(str, "\n")
|
||||
}
|
||||
@@ -54,6 +131,10 @@ func TranslateHttpsToSshUrl(url string) (string, error) {
|
||||
url2_len = len(url2)
|
||||
)
|
||||
|
||||
if len(url) > 10 && (url[0:10] == "gitea@src." || url[0:10] == "ssh://gite") {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
if len(url) > url1_len && url[0:url1_len] == url1 {
|
||||
return "ssh://gitea@src.opensuse.org/" + url[url1_len:], nil
|
||||
}
|
||||
@@ -132,7 +213,7 @@ func PRtoString(pr *models.PullRequest) string {
|
||||
return "(null)"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s#%d", pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index)
|
||||
return fmt.Sprintf("%s/%s!%d", pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index)
|
||||
}
|
||||
|
||||
type DevelProject struct {
|
||||
@@ -164,9 +245,10 @@ func FetchDevelProjects() (DevelProjects, error) {
|
||||
}
|
||||
|
||||
var DevelProjectNotFound = errors.New("Devel project not found")
|
||||
|
||||
func (d DevelProjects) GetDevelProject(pkg string) (string, error) {
|
||||
for _, item := range d {
|
||||
if item.Package == pkg {
|
||||
if item.Package == pkg {
|
||||
return item.Project, nil
|
||||
}
|
||||
}
|
||||
@@ -174,3 +256,33 @@ func (d DevelProjects) GetDevelProject(pkg string) (string, error) {
|
||||
return "", DevelProjectNotFound
|
||||
}
|
||||
|
||||
var removedBranchNameSuffixes []string = []string{
|
||||
"-rm",
|
||||
"-removed",
|
||||
"-deleted",
|
||||
}
|
||||
|
||||
func findRemovedBranchSuffix(branchName string) string {
|
||||
branchName = strings.ToLower(branchName)
|
||||
|
||||
for _, suffix := range removedBranchNameSuffixes {
|
||||
if len(suffix) < len(branchName) && strings.HasSuffix(branchName, suffix) {
|
||||
return suffix
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func IsRemovedBranch(branchName string) bool {
|
||||
return len(findRemovedBranchSuffix(branchName)) > 0
|
||||
}
|
||||
|
||||
func TrimRemovedBranchSuffix(branchName string) string {
|
||||
suffix := findRemovedBranchSuffix(branchName)
|
||||
if len(suffix) > 0 {
|
||||
return branchName[0 : len(branchName)-len(suffix)]
|
||||
}
|
||||
|
||||
return branchName
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
@@ -165,3 +166,142 @@ func TestRemoteName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedBranchName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
branchName string
|
||||
isRemoved bool
|
||||
regularName string
|
||||
}{
|
||||
{
|
||||
name: "Empty branch",
|
||||
},
|
||||
{
|
||||
name: "Removed suffix only",
|
||||
branchName: "-rm",
|
||||
isRemoved: false,
|
||||
regularName: "-rm",
|
||||
},
|
||||
{
|
||||
name: "Capital suffix",
|
||||
branchName: "Foo-Rm",
|
||||
isRemoved: true,
|
||||
regularName: "Foo",
|
||||
},
|
||||
{
|
||||
name: "Other suffixes",
|
||||
isRemoved: true,
|
||||
branchName: "Goo-Rm-DeleteD",
|
||||
regularName: "Goo-Rm",
|
||||
},
|
||||
{
|
||||
name: "Other suffixes",
|
||||
isRemoved: true,
|
||||
branchName: "main-REMOVED",
|
||||
regularName: "main",
|
||||
},
|
||||
{
|
||||
name: "Not removed separator",
|
||||
isRemoved: false,
|
||||
branchName: "main;REMOVED",
|
||||
regularName: "main;REMOVED",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if r := common.IsRemovedBranch(test.branchName); r != test.isRemoved {
|
||||
t.Error("Expecting isRemoved:", test.isRemoved, "but received", r)
|
||||
}
|
||||
|
||||
if tn := common.TrimRemovedBranchSuffix(test.branchName); tn != test.regularName {
|
||||
t.Error("Expected stripped branch name to be:", test.regularName, "but have:", tn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPackageIssueParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
issues *common.NewRepos
|
||||
}{
|
||||
{
|
||||
name: "Nothing",
|
||||
},
|
||||
{
|
||||
name: "Basic repo",
|
||||
input: "org/repo#branch",
|
||||
issues: &common.NewRepos{
|
||||
Repos: []struct{ Organization, Repository, Branch, PackageName string }{
|
||||
{Organization: "org", Repository: "repo", Branch: "branch", PackageName: "repo"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Default branch and junk lines and approval for maintainership",
|
||||
input: "\n\nsome comments\n\norg1/repo2\n\nmaintainership: yes",
|
||||
issues: &common.NewRepos{
|
||||
Repos: []struct{ Organization, Repository, Branch, PackageName string }{
|
||||
{Organization: "org1", Repository: "repo2", Branch: "", PackageName: "repo2"},
|
||||
},
|
||||
IsMaintainer: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Default branch and junk lines and no maintainership",
|
||||
input: "\n\nsome comments\n\norg1/repo2\n\nmaintainership: NEVER",
|
||||
issues: &common.NewRepos{
|
||||
Repos: []struct{ Organization, Repository, Branch, PackageName string }{
|
||||
{Organization: "org1", Repository: "repo2", Branch: "", PackageName: "repo2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "3 repos with comments and maintainership",
|
||||
input: "\n\nsome comments for org1/repo2 are here and more\n\norg1/repo2#master\n org2/repo3#master\n some/repo3#m\nMaintainer ok",
|
||||
issues: &common.NewRepos{
|
||||
Repos: []struct{ Organization, Repository, Branch, PackageName string }{
|
||||
{Organization: "org1", Repository: "repo2", Branch: "master", PackageName: "repo2"},
|
||||
{Organization: "org2", Repository: "repo3", Branch: "master", PackageName: "repo3"},
|
||||
{Organization: "some", Repository: "repo3", Branch: "m", PackageName: "repo3"},
|
||||
},
|
||||
IsMaintainer: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid repos with spaces",
|
||||
input: "or g/repo#branch\norg/r epo#branch\norg/repo#br anch\norg/repo#branch As foo ++",
|
||||
},
|
||||
{
|
||||
name: "Valid repos with spaces",
|
||||
input: " org / repo # branch",
|
||||
issues: &common.NewRepos{
|
||||
Repos: []struct{ Organization, Repository, Branch, PackageName string }{
|
||||
{Organization: "org", Repository: "repo", Branch: "branch", PackageName: "repo"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Package name is not repo name",
|
||||
input: " org / repo # branch as repo++ \nmaintainer true",
|
||||
issues: &common.NewRepos{
|
||||
Repos: []struct{ Organization, Repository, Branch, PackageName string }{
|
||||
{Organization: "org", Repository: "repo", Branch: "branch", PackageName: "repo++"},
|
||||
},
|
||||
IsMaintainer: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
issue := common.FindNewReposInIssueBody(test.input)
|
||||
if !reflect.DeepEqual(test.issues, issue) {
|
||||
t.Error("Expected", test.issues, "but have", issue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
8
containers/Makefile
Normal file
8
containers/Makefile
Normal file
@@ -0,0 +1,8 @@
|
||||
all: ../workflow-direct/workflow-direct
|
||||
cp ../workflow-direct/workflow-direct workflow-direct
|
||||
podman build --pull=always -t workflow-direct workflow-direct
|
||||
|
||||
pr:
|
||||
cp ../workflow-pr/workflow-pr workflow-pr
|
||||
podman build --pull=always -t workflow-pr workflow-pr
|
||||
|
||||
1
containers/workflow-direct/.gitignore
vendored
Normal file
1
containers/workflow-direct/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
workflow-direct
|
||||
14
containers/workflow-direct/Containerfile
Normal file
14
containers/workflow-direct/Containerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM registry.suse.com/bci/bci-base
|
||||
RUN zypper install -y openssh-clients git-core
|
||||
RUN mkdir /root/.ssh
|
||||
RUN mkdir /repos
|
||||
RUN ln -s /data/workflow-direct.key /root/.ssh/id_ed25519
|
||||
RUN ln -s /data/workflow-direct.key.pub /root/.ssh/id_ed25519.pub
|
||||
ADD known_hosts /root/.ssh/known_hosts
|
||||
ADD workflow-direct /srv/workflow-direct
|
||||
ENV AMQP_USERNAME=opensuse
|
||||
ENV AMQP_PASSWORD=opensuse
|
||||
VOLUME /data
|
||||
VOLUME /repos
|
||||
ENTRYPOINT /srv/workflow-direct -config /data/config.json -repo-path /repos -debug -check-on-start
|
||||
|
||||
4
containers/workflow-direct/known_hosts
Normal file
4
containers/workflow-direct/known_hosts
Normal file
@@ -0,0 +1,4 @@
|
||||
src.opensuse.org,195.135.223.224 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDJ8V51MVIFUkQqQOdHwC3SP9NPqp1ZWYoEbcjvZ7HhSFi2XF8ALo/h1Mk+q8kT2O75/goeTsKFbcU8zrYFeOh0=
|
||||
src.opensuse.org,195.135.223.224 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCkVeXePin0haffC085V2L0jvILfwbB2Mt1fpVe21QAOcWNM+/jOC5RwtWweV/LigHImB39/KvkuPa9yLoDf+eLhdZQckSSauRfDjxtlKeFLPrfJKSA0XeVJT3kJcOvDT/3ANFhYeBbAUBTAeQt5bi2hHC1twMPbaaEdJ2jiMaIBztFf6aE9K58uoS+7Y2tTv87Mv/7lqoBW6BFMoDmjQFWgjik6ZMCvIM/7bj7AgqHk/rjmr5zKS4ag5wtHtYLm1L3LBmHdj7d0VFsOpPQexIOEnnjzKqlwmAxT6eYJ/t3qgBlT8KRfshBFgEuUZ5GJOC7TOne4PfB0bboPMZzIRo3WE9dPGRR8kAIme8XqhFbmjdJ+WsTjg0Lj+415tIbyRQoNkLtawrJxozvevs6wFEFcA/YG6o03Z577tiLT3WxOguCcD5vrALH48SyZb8jDUtcVgTWMW0to/n63S8JGUNyF7Bkw9HQWUx+GO1cv2GNzKpk22KS5dlNUVGE9E/7Ydc=
|
||||
src.opensuse.org,195.135.223.224 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFKNThLRPznU5Io1KrAYHmYpaoLQEMGM9nwpKyYQCkPx
|
||||
|
||||
1
containers/workflow-pr/.gitignore
vendored
Normal file
1
containers/workflow-pr/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
workflow-pr
|
||||
14
containers/workflow-pr/Containerfile
Normal file
14
containers/workflow-pr/Containerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM registry.suse.com/bci/bci-base
|
||||
RUN zypper install -y openssh-clients git-core
|
||||
RUN mkdir /root/.ssh
|
||||
RUN mkdir /repos
|
||||
RUN ln -s /data/workflow-pr.key /root/.ssh/id_ed25519
|
||||
RUN ln -s /data/workflow-pr.key.pub /root/.ssh/id_ed25519.pub
|
||||
ADD known_hosts /root/.ssh/known_hosts
|
||||
ADD workflow-pr /srv/workflow-pr
|
||||
ENV AMQP_USERNAME=opensuse
|
||||
ENV AMQP_PASSWORD=opensuse
|
||||
VOLUME /data
|
||||
VOLUME /repos
|
||||
ENTRYPOINT /srv/workflow-pr -config /data/config.json -repo-path /repos -debug -check-on-start
|
||||
|
||||
4
containers/workflow-pr/known_hosts
Normal file
4
containers/workflow-pr/known_hosts
Normal file
@@ -0,0 +1,4 @@
|
||||
src.opensuse.org,195.135.223.224 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDJ8V51MVIFUkQqQOdHwC3SP9NPqp1ZWYoEbcjvZ7HhSFi2XF8ALo/h1Mk+q8kT2O75/goeTsKFbcU8zrYFeOh0=
|
||||
src.opensuse.org,195.135.223.224 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCkVeXePin0haffC085V2L0jvILfwbB2Mt1fpVe21QAOcWNM+/jOC5RwtWweV/LigHImB39/KvkuPa9yLoDf+eLhdZQckSSauRfDjxtlKeFLPrfJKSA0XeVJT3kJcOvDT/3ANFhYeBbAUBTAeQt5bi2hHC1twMPbaaEdJ2jiMaIBztFf6aE9K58uoS+7Y2tTv87Mv/7lqoBW6BFMoDmjQFWgjik6ZMCvIM/7bj7AgqHk/rjmr5zKS4ag5wtHtYLm1L3LBmHdj7d0VFsOpPQexIOEnnjzKqlwmAxT6eYJ/t3qgBlT8KRfshBFgEuUZ5GJOC7TOne4PfB0bboPMZzIRo3WE9dPGRR8kAIme8XqhFbmjdJ+WsTjg0Lj+415tIbyRQoNkLtawrJxozvevs6wFEFcA/YG6o03Z577tiLT3WxOguCcD5vrALH48SyZb8jDUtcVgTWMW0to/n63S8JGUNyF7Bkw9HQWUx+GO1cv2GNzKpk22KS5dlNUVGE9E/7Ydc=
|
||||
src.opensuse.org,195.135.223.224 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFKNThLRPznU5Io1KrAYHmYpaoLQEMGM9nwpKyYQCkPx
|
||||
|
||||
1
devel-importer/.gitignore
vendored
1
devel-importer/.gitignore
vendored
@@ -2,3 +2,4 @@ devel-importer
|
||||
Factory
|
||||
git
|
||||
git-migrated
|
||||
git-importer
|
||||
|
||||
239
devel-importer/find_factory_commit.pl
Executable file
239
devel-importer/find_factory_commit.pl
Executable file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/perl
|
||||
use strict;
|
||||
use warnings;
|
||||
use IPC::Open2;
|
||||
use JSON;
|
||||
|
||||
sub FindFactoryCommit {
|
||||
my ($package) = @_;
|
||||
|
||||
# Execute osc cat and capture output
|
||||
my $osc_cmd = "osc cat openSUSE:Factory $package $package.changes";
|
||||
open( my $osc_fh, "$osc_cmd |" ) or die "Failed to run osc: $!";
|
||||
my $data = do { local $/; <$osc_fh> };
|
||||
close($osc_fh);
|
||||
|
||||
# Calculate size
|
||||
my $size = length($data);
|
||||
|
||||
# Create blob header
|
||||
my $blob = "blob $size\0$data";
|
||||
|
||||
# Open a pipe to openssl to compute the hash
|
||||
my ( $reader, $writer );
|
||||
my $pid = open2( $reader, $writer, "openssl sha256" );
|
||||
|
||||
# Send blob data
|
||||
print $writer $blob;
|
||||
close $writer;
|
||||
|
||||
# Read the hash result and extract it
|
||||
my $hash_line = <$reader>;
|
||||
waitpid( $pid, 0 );
|
||||
my ($hash) = $hash_line =~ /([a-fA-F0-9]{64})/;
|
||||
|
||||
# Run git search command with the hash
|
||||
print("looking for hash: $hash\n");
|
||||
my @hashes;
|
||||
my $git_cmd =
|
||||
"git -C $package rev-list --all pool/HEAD | while read commit; do git -C $package ls-tree \"\$commit\" | grep -q '^100644 blob $hash' && echo \"\$commit\"; done";
|
||||
open( my $git_fh, "$git_cmd |" ) or die "Failed to run git search: $!";
|
||||
while ( my $commit = <$git_fh> ) {
|
||||
chomp $commit;
|
||||
print "Found commit $commit\n";
|
||||
push( @hashes, $commit );
|
||||
}
|
||||
close($git_fh);
|
||||
return @hashes;
|
||||
}
|
||||
|
||||
sub ListPackages {
|
||||
my ($project) = @_;
|
||||
open( my $osc_fh,
|
||||
"curl -s https://src.opensuse.org/openSUSE/Factory/raw/branch/main/pkgs/_meta/devel_packages | awk '{ if ( \$2 == \"$project\" ) print \$1 }' |" )
|
||||
or die "Failed to run curl: $!";
|
||||
my @packages = <$osc_fh>;
|
||||
chomp @packages;
|
||||
close($osc_fh);
|
||||
return @packages;
|
||||
}
|
||||
|
||||
sub FactoryMd5 {
|
||||
my ($package) = @_;
|
||||
my $out = "";
|
||||
|
||||
if (system("osc ls openSUSE:Factory $package | grep -q build.specials.obscpio") == 0) {
|
||||
system("mkdir _extract") == 0 || die "_extract exists or can't make it. Aborting.";
|
||||
chdir("_extract") || die;
|
||||
system("osc cat openSUSE:Factory $package build.specials.obscpio | cpio -dium 2> /dev/null") == 0 || die;
|
||||
system("rm .* 2> /dev/null");
|
||||
open( my $fh, "find -type f -exec /usr/bin/basename {} \\; | xargs md5sum | awk '{print \$1 FS \$2}' | grep -v d41d8cd98f00b204e9800998ecf8427e |") or die;
|
||||
while ( my $l = <$fh>) {
|
||||
$out = $out.$l;
|
||||
}
|
||||
close($fh);
|
||||
chdir("..") && system("rm -rf _extract") == 0 || die;
|
||||
}
|
||||
open( my $fh, "osc ls -v openSUSE:Factory $package | awk '{print \$1 FS \$7}' | grep -v -F '_scmsync.obsinfo\nbuild.specials.obscpio' |") or die;
|
||||
while (my $l = <$fh>) {
|
||||
$out = $out.$l;
|
||||
}
|
||||
close($fh);
|
||||
return $out;
|
||||
}
|
||||
|
||||
# Read project from first argument
|
||||
sub Usage {
|
||||
die "Usage: $0 <OBS Project> [org [package]]";
|
||||
}
|
||||
|
||||
my $project = shift or Usage();
|
||||
my $org = shift;
|
||||
|
||||
if (not defined($org)) {
|
||||
$org = `osc meta prj $project | grep scmsync | sed -e 's,^.*src.opensuse.org/\\(.*\\)/_ObsPrj.*,\\1,'`;
|
||||
chomp($org);
|
||||
}
|
||||
|
||||
my @packages = ListPackages($project);
|
||||
my $pkg = shift;
|
||||
@packages = ($pkg) if defined $pkg;
|
||||
|
||||
my @tomove;
|
||||
my @toremove;
|
||||
|
||||
if ( ! -e $org ) {
|
||||
mkdir($org);
|
||||
}
|
||||
chdir($org);
|
||||
print "Verify packages in /pool for $org package in $project\n";
|
||||
|
||||
my $super_user = $ENV{SUPER};
|
||||
if (defined($super_user)) {
|
||||
$super_user = "-G $super_user";
|
||||
} else {
|
||||
$super_user = "";
|
||||
}
|
||||
|
||||
my @missing;
|
||||
|
||||
# verify that packages in devel project is a fork from pool.
|
||||
for my $pkg ( sort(@packages) ) {
|
||||
my $data = `git obs api /repos/$org/$pkg 2> /dev/null`;
|
||||
if ( length($data) == 0 ) {
|
||||
print "***** Repo missing in $org: $pkg\n";
|
||||
push(@missing, $pkg);
|
||||
next;
|
||||
}
|
||||
else {
|
||||
my $repo = decode_json($data);
|
||||
if ( !$repo->{parent}
|
||||
|| $repo->{parent}->{owner}->{username} ne "pool" )
|
||||
{
|
||||
if ( system("git obs api /repos/pool/$pkg > /dev/null 2> /dev/null") == 0 ) {
|
||||
print "=== $pkg NOT A FORK of exiting package\n";
|
||||
push( @toremove, $pkg );
|
||||
}
|
||||
else {
|
||||
print "$pkg NEEDS transfer\n";
|
||||
push( @tomove, $pkg );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( scalar @missing > 0 ) {
|
||||
for my $pkg (@missing) {
|
||||
my $index = 0;
|
||||
$index++ until $packages[$index] eq $pkg;
|
||||
splice(@packages, $index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if ( scalar @toremove > 0 ) {
|
||||
print "ABORTING. Need repos removed.\n";
|
||||
print "@toremove\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ( scalar @tomove > 0 ) {
|
||||
for my $pkg (@tomove) {
|
||||
system("git obs $super_user api -X POST --data '{\"reparent\": true, \"organization\": \"pool\"}' /repos/$org/$pkg/forks") == 0 and
|
||||
system("git clone gitea\@src.opensuse.org:pool/$pkg") == 0 and
|
||||
system("git -C $pkg checkout -B factory HEAD") == 0 and
|
||||
system("git -C $pkg push origin factory") == 0 and
|
||||
system("git obs $super_user api -X PATCH --data '{\"default_branch\": \"factory\"}' /repos/pool/$pkg") == 0
|
||||
or die "Error in creating a pool repo";
|
||||
system("for i in \$(git -C $pkg for-each-ref --format='%(refname:lstrip=3)' refs/remotes/origin/ | grep -v '\\(^HEAD\$\\|^factory\$\\)'); do git -C $pkg push origin :\$i; done") == 0 or die "failed to cull branches";
|
||||
}
|
||||
}
|
||||
|
||||
print "Verify complete.\n";
|
||||
|
||||
for my $package ( sort(@packages) ) {
|
||||
print " ----- PROCESSING $package\n";
|
||||
my $url = "https://src.opensuse.org/$org/$package.git";
|
||||
my $push_url = "gitea\@src.opensuse.org:pool/$package.git";
|
||||
if ( not -e $package ) {
|
||||
print("cloning...\n");
|
||||
system("git clone --origin pool $url") == 0
|
||||
or die "Can't clone $org/$package";
|
||||
}
|
||||
else {
|
||||
print("adding remote...\n");
|
||||
system("git -C $package remote rm pool > /dev/null");
|
||||
system("git -C $package remote add pool $url") == 0
|
||||
or die "Can't add pool for $package";
|
||||
}
|
||||
system("git -C $package remote set-url pool --push $push_url") == 0
|
||||
or die "Can't add push remote for $package";
|
||||
print("fetching remote...\n");
|
||||
system("git -C $package fetch pool") == 0
|
||||
or ( push( @tomove, $package ) and die "Can't fetch pool for $package" );
|
||||
|
||||
my @commits = FindFactoryCommit($package);
|
||||
my $Md5Hashes = FactoryMd5($package);
|
||||
my $c;
|
||||
my $match = 0;
|
||||
for my $commit (@commits) {
|
||||
if ( length($commit) != 64 ) {
|
||||
print("Failed to find factory commit. Aborting.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (
|
||||
system("git -C $package lfs fetch pool $commit") == 0
|
||||
and system("git -C $package checkout -B factory $commit") == 0
|
||||
and system("git -C $package lfs checkout") == 0
|
||||
and chdir($package)) {
|
||||
|
||||
open(my $fh, "|-", "md5sum -c --quiet") or die $!;
|
||||
print $fh $Md5Hashes;
|
||||
close $fh;
|
||||
if ($? >> 8 != 0) {
|
||||
chdir("..") || die;
|
||||
next;
|
||||
}
|
||||
open($fh, "|-", "awk '{print \$2}' | sort | bash -c \"diff <(ls -1 | sort) -\"") or die $!;
|
||||
print $fh $Md5Hashes;
|
||||
close $fh;
|
||||
my $ec = $? >> 8;
|
||||
chdir("..") || die;
|
||||
|
||||
if ($ec == 0) {
|
||||
$c = $commit;
|
||||
$match = 1;
|
||||
last;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if ( !$match ) {
|
||||
die "Match not found. Aborting.";
|
||||
}
|
||||
|
||||
system ("git -C $package push -f pool factory");
|
||||
print "$package: $c\n";
|
||||
}
|
||||
|
||||
187
devel-importer/find_factory_commit_in_gitpkg.pl
Executable file
187
devel-importer/find_factory_commit_in_gitpkg.pl
Executable file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/perl
|
||||
use strict;
|
||||
use warnings;
|
||||
use IPC::Open2;
|
||||
use URI;
|
||||
|
||||
sub FindFactoryCommit {
|
||||
my ($package) = @_;
|
||||
|
||||
# Execute osc cat and capture output
|
||||
my $osc_cmd = "osc cat openSUSE:Factory $package $package.changes";
|
||||
open( my $osc_fh, "$osc_cmd |" ) or die "Failed to run osc: $!";
|
||||
my $data = do { local $/; <$osc_fh> };
|
||||
close($osc_fh);
|
||||
|
||||
# Calculate size
|
||||
my $size = length($data);
|
||||
|
||||
# Create blob header
|
||||
my $blob = "blob $size\0$data";
|
||||
|
||||
# Open a pipe to openssl to compute the hash
|
||||
my ( $reader, $writer );
|
||||
my $pid = open2( $reader, $writer, "openssl sha256" );
|
||||
|
||||
# Send blob data
|
||||
print $writer $blob;
|
||||
close $writer;
|
||||
|
||||
# Read the hash result and extract it
|
||||
my $hash_line = <$reader>;
|
||||
waitpid( $pid, 0 );
|
||||
my ($hash) = $hash_line =~ /([a-fA-F0-9]{64})/;
|
||||
|
||||
# Run git search command with the hash
|
||||
print("looking for hash: $hash\n");
|
||||
my @hashes;
|
||||
my $git_cmd =
|
||||
"git -C $package rev-list --all pool/HEAD | while read commit; do git -C $package ls-tree \"\$commit\" | grep -q '^100644 blob $hash' && echo \"\$commit\"; done";
|
||||
open( my $git_fh, "$git_cmd |" ) or die "Failed to run git search: $!";
|
||||
while ( my $commit = <$git_fh> ) {
|
||||
chomp $commit;
|
||||
print "Found commit $commit\n";
|
||||
push( @hashes, $commit );
|
||||
}
|
||||
close($git_fh);
|
||||
return @hashes;
|
||||
}
|
||||
|
||||
sub FactoryMd5 {
|
||||
my ($package) = @_;
|
||||
my $out = "";
|
||||
|
||||
if (system("osc ls openSUSE:Factory $package | grep -q build.specials.obscpio") == 0) {
|
||||
system("mkdir _extract") == 0 || die "_extract exists or can't make it. Aborting.";
|
||||
chdir("_extract") || die;
|
||||
system("osc cat openSUSE:Factory $package build.specials.obscpio | cpio -dium 2> /dev/null") == 0 || die;
|
||||
system("rm .* 2> /dev/null");
|
||||
open( my $fh, "find -type f -exec /usr/bin/basename {} \\; | xargs md5sum | awk '{print \$1 FS \$2}' | grep -v d41d8cd98f00b204e9800998ecf8427e |") or die;
|
||||
while ( my $l = <$fh>) {
|
||||
$out = $out.$l;
|
||||
}
|
||||
close($fh);
|
||||
chdir("..") && system("rm -rf _extract") == 0 || die;
|
||||
}
|
||||
open( my $fh, "osc ls -v openSUSE:Factory $package | awk '{print \$1 FS \$7}' | grep -v -F '_scmsync.obsinfo\nbuild.specials.obscpio' |") or die;
|
||||
while (my $l = <$fh>) {
|
||||
$out = $out.$l;
|
||||
}
|
||||
close($fh);
|
||||
return $out;
|
||||
}
|
||||
|
||||
# Read project from first argument
|
||||
sub Usage {
|
||||
die "Usage: $0 <OBS Project> <package> <repo>";
|
||||
}
|
||||
|
||||
my $project = shift or Usage();
|
||||
my $pkg = shift;
|
||||
my $repo = shift;
|
||||
|
||||
if (not defined($repo)) {
|
||||
Usage();
|
||||
}
|
||||
|
||||
|
||||
my $meta_url = `osc meta pkg $project $pkg | grep scmsync | sed -e 's,\\s*</\\?scmsync>\\s*,,g'`;
|
||||
chomp($meta_url);
|
||||
if ($meta_url ne $repo) {
|
||||
die "meta not equal to repo for $pkg: $meta_url != $repo";
|
||||
}
|
||||
|
||||
my $u = URI->new($meta_url);
|
||||
die "Only src.opensuse.org is supported" unless $u->scheme =~ /^https?$/ && $u->host eq 'src.opensuse.org';
|
||||
my (undef, $org, $repo_path) = split('/', $u->path);
|
||||
my $branch = $u->fragment;
|
||||
die "Only src.opensuse.org is supported" unless $org;
|
||||
if ($org eq "pool") {
|
||||
print "Already a pool package. We are done.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
my %params = $u->query_form;
|
||||
delete $params{trackingbranch};
|
||||
die "Unsupported query parameters: " . join(', ', keys %params) if keys %params;
|
||||
|
||||
my @packages = ($pkg) if defined $pkg;
|
||||
|
||||
if ( ! -e $org ) {
|
||||
mkdir($org);
|
||||
}
|
||||
chdir($org);
|
||||
|
||||
my $super_user = $ENV{SUPER};
|
||||
if (defined($super_user)) {
|
||||
$super_user = "-G $super_user";
|
||||
} else {
|
||||
$super_user = "";
|
||||
}
|
||||
|
||||
for my $package ( sort(@packages) ) {
|
||||
print " ----- PROCESSING $package\n";
|
||||
my $url = "https://src.opensuse.org/$org/$repo_path.git";
|
||||
my $push_url = "gitea\@src.opensuse.org:pool/$package.git";
|
||||
if ( not -e $package ) {
|
||||
print("cloning...\n");
|
||||
system("git clone --origin pool $url $package") == 0
|
||||
or die "Can't clone $org/$repo_path";
|
||||
}
|
||||
else {
|
||||
print("adding remote...\n");
|
||||
system("git -C $package remote rm pool > /dev/null");
|
||||
system("git -C $package remote add pool $url") == 0
|
||||
or die "Can't add pool for $package";
|
||||
}
|
||||
system("git -C $package remote set-url pool --push $push_url") == 0
|
||||
or die "Can't add push remote for $package";
|
||||
print("fetching remote...\n");
|
||||
system("git -C $package fetch pool") == 0 or die "Can't fetch pool for $package";
|
||||
|
||||
my @commits = FindFactoryCommit($package);
|
||||
my $Md5Hashes = FactoryMd5($package);
|
||||
my $c;
|
||||
my $match = 0;
|
||||
for my $commit (@commits) {
|
||||
if ( length($commit) != 64 ) {
|
||||
print("Failed to find factory commit. Aborting.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (
|
||||
system("git -C $package lfs fetch pool $commit") == 0
|
||||
and system("git -C $package checkout -B factory $commit") == 0
|
||||
and system("git -C $package lfs checkout") == 0
|
||||
and chdir($package)) {
|
||||
|
||||
open(my $fh, "|-", "md5sum -c --quiet") or die $!;
|
||||
print $fh $Md5Hashes;
|
||||
close $fh;
|
||||
if ($? >> 8 != 0) {
|
||||
chdir("..") || die;
|
||||
next;
|
||||
}
|
||||
open($fh, "|-", "awk '{print \$2}' | sort | bash -c \"diff <(ls -1 | sort) -\"") or die $!;
|
||||
print $fh $Md5Hashes;
|
||||
close $fh;
|
||||
my $ec = $? >> 8;
|
||||
chdir("..") || die;
|
||||
|
||||
if ($ec == 0) {
|
||||
$c = $commit;
|
||||
$match = 1;
|
||||
last;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if ( !$match ) {
|
||||
die "Match not found. Aborting.";
|
||||
}
|
||||
|
||||
system ("git -C $package push -f pool factory");
|
||||
print "$package: $c\n";
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,25 @@
|
||||
SystemsManagement
|
||||
Java:packages
|
||||
Kernel:firmware
|
||||
Kernel:kdump
|
||||
devel:gcc
|
||||
devel:languages:clojure
|
||||
devel:languages:erlang
|
||||
devel:languages:erlang:Factory
|
||||
devel:languages:hare
|
||||
devel:languages:javascript
|
||||
devel:languages:lua
|
||||
devel:languages:nodejs
|
||||
devel:languages:perl
|
||||
devel:languages:python:Factory
|
||||
devel:languages:python:pytest
|
||||
devel:openSUSE:Factory
|
||||
network:chromium
|
||||
network:dhcp
|
||||
network:im:whatsapp
|
||||
network:messaging:xmpp
|
||||
science:HPC
|
||||
server:dns
|
||||
systemsmanagement:cockpit
|
||||
systemsmanagement:wbem
|
||||
X11:lxde
|
||||
|
||||
|
||||
4
devel-importer/update_git_packages.sh
Executable file
4
devel-importer/update_git_packages.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
osc api '/search/package?match=scmsync' | ../xml_package_parse | ../find_factory_commit_in_gitpkg.pl
|
||||
|
||||
83
devel-importer/xml_package_parse
Executable file
83
devel-importer/xml_package_parse
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/perl
|
||||
use strict;
|
||||
use warnings;
|
||||
use XML::Parser;
|
||||
|
||||
my $parser = XML::Parser->new(Handlers => {
|
||||
Start => \&handle_start,
|
||||
End => \&handle_end,
|
||||
Char => \&handle_char,
|
||||
});
|
||||
|
||||
my $current_element = '';
|
||||
my $current_package_attrs = {};
|
||||
my $scmsync_content = '';
|
||||
|
||||
my %devel_pkgs;
|
||||
open(my $dfh, "curl -s https://src.opensuse.org/openSUSE/Factory/raw/branch/main/pkgs/_meta/devel_packages |") or die $!;
|
||||
while(<$dfh>) {
|
||||
chomp;
|
||||
$devel_pkgs{$_} = 1;
|
||||
}
|
||||
close($dfh);
|
||||
|
||||
my $xml_content = do { local $/; <STDIN> };
|
||||
$parser->parse($xml_content);
|
||||
|
||||
sub handle_start {
|
||||
my ($expat, $element, %attrs) = @_;
|
||||
$current_element = $element;
|
||||
|
||||
if ($element eq 'package') {
|
||||
$current_package_attrs = \%attrs;
|
||||
}
|
||||
if ($element eq 'scmsync') {
|
||||
$scmsync_content = '';
|
||||
}
|
||||
}
|
||||
|
||||
sub handle_char {
|
||||
my ($expat, $string) = @_;
|
||||
if ($current_element eq 'scmsync') {
|
||||
$scmsync_content .= $string;
|
||||
}
|
||||
}
|
||||
|
||||
sub handle_end {
|
||||
my ($expat, $element) = @_;
|
||||
|
||||
if ($element eq 'scmsync') {
|
||||
my $project = $current_package_attrs->{project};
|
||||
my $name = $current_package_attrs->{name};
|
||||
my $scmsync = $scmsync_content;
|
||||
|
||||
# Use checks
|
||||
$project = '' unless defined $project;
|
||||
$name = '' unless defined $name;
|
||||
$scmsync = '' unless defined $scmsync;
|
||||
|
||||
# Trim
|
||||
$project =~ s/^\s+|\s+$//g;
|
||||
$name =~ s/^\s+|\s+$//g;
|
||||
$scmsync =~ s/^\s+|\s+$//g;
|
||||
|
||||
my $has_error = 0;
|
||||
foreach my $val ($project, $name, $scmsync) {
|
||||
if ($val =~ /\s/) {
|
||||
print STDERR "Error: Value '$val' contains whitespace.\n";
|
||||
$has_error = 1;
|
||||
}
|
||||
}
|
||||
|
||||
unless ($has_error) {
|
||||
if ($devel_pkgs{"$name $project"}) {
|
||||
print "$name $project $scmsync\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Reset current element if we are closing it
|
||||
if ($current_element eq $element) {
|
||||
$current_element = '';
|
||||
}
|
||||
}
|
||||
@@ -298,6 +298,22 @@ func parseRequestJSONOrg(reqType string, data []byte) (org *common.Organization,
|
||||
org = pr.Repository.Owner
|
||||
extraAction = ""
|
||||
|
||||
case common.RequestType_Status:
|
||||
status := common.StatusWebhookEvent{}
|
||||
if err = json.Unmarshal(data, &status); err != nil {
|
||||
return
|
||||
}
|
||||
switch status.State {
|
||||
case "pending", "success", "error", "failure":
|
||||
break
|
||||
default:
|
||||
err = fmt.Errorf("Unknown Status' state: %s", status.State)
|
||||
return
|
||||
}
|
||||
|
||||
org = status.Repository.Owner
|
||||
extraAction = status.State
|
||||
|
||||
case common.RequestType_Wiki:
|
||||
wiki := common.WikiWebhookEvent{}
|
||||
if err = json.Unmarshal(data, &wiki); err != nil {
|
||||
|
||||
46
gitea_status_proxy/config.go
Normal file
46
gitea_status_proxy/config.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ForgeEndpoint string `json:"forge_url"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const configKey contextKey = "config"
|
||||
|
||||
func ReadConfig(reader io.Reader) (*Config, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading config data: %w", err)
|
||||
}
|
||||
config := Config{}
|
||||
data, err = hujson.Standardize(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse json: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("error parsing json to api keys and target url: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func ReadConfigFile(filename string) (*Config, error) {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot open config file for reading. err: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return ReadConfig(file)
|
||||
}
|
||||
15
gitea_status_proxy/handlers.go
Normal file
15
gitea_status_proxy/handlers.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func ConfigMiddleWare(cfg *Config) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), configKey, cfg)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
164
gitea_status_proxy/main.go
Normal file
164
gitea_status_proxy/main.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
type StatusInput struct {
|
||||
Description string `json:"description"`
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
TargetUrl string `json:"target_url"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
configFile := flag.String("config", "", "status proxy config file")
|
||||
flag.Parse()
|
||||
|
||||
if *configFile == "" {
|
||||
common.LogError("missing required argument config")
|
||||
return
|
||||
}
|
||||
|
||||
config, err := ReadConfigFile(*configFile)
|
||||
|
||||
if err != nil {
|
||||
common.LogError("Failed to read config file", err)
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("/repos/{owner}/{repo}/statuses/{sha}", ConfigMiddleWare(config)(http.HandlerFunc(StatusProxy)))
|
||||
|
||||
common.LogInfo("server up and listening on :3000")
|
||||
err = http.ListenAndServe(":3000", mux)
|
||||
|
||||
if err != nil {
|
||||
common.LogError("Server failed to start up", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func StatusProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
config, ok := r.Context().Value(configKey).(*Config)
|
||||
|
||||
if !ok {
|
||||
common.LogDebug("Config missing from context")
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
common.LogDebug("Authorization header not found")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
token_arr := strings.Split(header, " ")
|
||||
if len(token_arr) != 2 {
|
||||
common.LogDebug("Authorization header malformed")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(token_arr[0], "token") {
|
||||
common.LogDebug("Token not found in Authorization header")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := token_arr[1]
|
||||
|
||||
if !slices.Contains(config.Keys, token) {
|
||||
common.LogDebug("Provided token is not known")
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
owner := r.PathValue("owner")
|
||||
repo := r.PathValue("repo")
|
||||
sha := r.PathValue("sha")
|
||||
|
||||
if !ok {
|
||||
common.LogError("Failed to get config from context, is it set?")
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
posturl := fmt.Sprintf("%s/repos/%s/%s/statuses/%s", config.ForgeEndpoint, owner, repo, sha)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var statusinput StatusInput
|
||||
err := decoder.Decode(&statusinput)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
status_payload, err := json.Marshal(statusinput)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("POST", posturl, bytes.NewBuffer(status_payload))
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ForgeToken := os.Getenv("GITEA_TOKEN")
|
||||
|
||||
if ForgeToken == "" {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
common.LogError("GITEA_TOKEN was not set, all requests will fail")
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Authorization", fmt.Sprintf("token %s", ForgeToken))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
common.LogError(fmt.Sprintf("Request to forge endpoint failed: %v", err))
|
||||
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
/*
|
||||
the commented out section sets every key
|
||||
value from the headers, unsure if this
|
||||
leaks information from gitea
|
||||
|
||||
for k, v := range resp.Header {
|
||||
for _, vv := range v {
|
||||
w.Header().Add(k, vv)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
_, err = io.Copy(w, resp.Body)
|
||||
if err != nil {
|
||||
common.LogError("Error copying response body: %v", err)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
}
|
||||
48
gitea_status_proxy/readme.md
Normal file
48
gitea_status_proxy/readme.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# gitea_status_proxy
|
||||
|
||||
Allows bots without code owner permission to set Gitea's commit status
|
||||
|
||||
## Basic usage
|
||||
|
||||
To beging, you need the json config and a Gitea token with permissions to the repository you want to write to.
|
||||
|
||||
Keys should be randomly generated, i.e by using openssl: `openssl rand -base64 48`
|
||||
|
||||
Generate a json config file, with the key generated from running the command above, save as example.json:
|
||||
|
||||
```
|
||||
{
|
||||
"forge_url": "https://src.opensuse.org/api/v1",
|
||||
"keys": ["$YOUR_TOKEN_GOES_HERE"]
|
||||
}
|
||||
```
|
||||
|
||||
### start the proxy:
|
||||
|
||||
```
|
||||
GITEA_TOKEN=YOURTOKEN ./gitea_status_proxy -config example.json
|
||||
2025/10/30 12:53:18 [I] server up and listening on :3000
|
||||
```
|
||||
|
||||
Now the proxy should be able to accept requests under: `localhost:3000/repos/{owner}/{repo}/statuses/{sha}`, the token to be used when authenticating to the proxy must be in the `keys` list of the configuration json file (example.json above)
|
||||
|
||||
### example:
|
||||
|
||||
On a separate terminal, you can use curl to post a status to the proxy, if the GITEA_TOKEN has permissions on the target
|
||||
repository, it will result in a new status being set for the given commit
|
||||
|
||||
```
|
||||
curl -X 'POST' \
|
||||
'localhost:3000/repos/szarate/test-actions-gitea/statuses/cd5847c92fb65a628bdd6015f96ee7e569e1ad6e4fc487acc149b52e788262f9' \
|
||||
-H 'accept: application/json' \
|
||||
-H 'Authorization: token $YOUR_TOKEN_GOES_HERE' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"context": "Proxy test",
|
||||
"description": "Status posted from the proxy",
|
||||
"state": "success",
|
||||
"target_url": "https://src.opensuse.org"
|
||||
}'
|
||||
```
|
||||
|
||||
After this you should be able to the results in the pull request, e.g from above: https://src.opensuse.org/szarate/test-actions-gitea/pulls/1
|
||||
5
go.mod
5
go.mod
@@ -10,12 +10,16 @@ require (
|
||||
github.com/go-openapi/validate v0.24.0
|
||||
github.com/opentracing/opentracing-go v1.2.0
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/redis/go-redis/v9 v9.11.0
|
||||
github.com/tailscale/hujson v0.0.0-20250226034555-ec1d1c113d33
|
||||
go.uber.org/mock v0.5.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/analysis v0.23.0 // indirect
|
||||
@@ -33,5 +37,4 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
10
go.sum
10
go.sum
@@ -1,8 +1,16 @@
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -50,6 +58,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
|
||||
github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
@@ -1,24 +1,65 @@
|
||||
Group Review Bot
|
||||
================
|
||||
|
||||
Areas of responsibility
|
||||
-----------------------
|
||||
This workaround is mainly needed because Gitea does not track which team member performed a review on behalf of a team.
|
||||
|
||||
1. Is used to handle reviews associated with groups defined in the
|
||||
ProjectGit.
|
||||
Main Tasks
|
||||
----------
|
||||
|
||||
2. Assumes: workflow-pr needs to associate and define the PR set from
|
||||
which the groups.json is read (Base of the PrjGit PR)
|
||||
Awaits a comment in the format “@groupreviewbot-name: approve”, then approves the PR with the comment “<user> approved a review on behalf of <groupreviewbot-name>.”
|
||||
|
||||
Target Usage
|
||||
------------
|
||||
|
||||
Projects where policy reviews are required.
|
||||
|
||||
Configuration
|
||||
--------------
|
||||
|
||||
The bot is configured via the `ReviewGroups` field in the `workflow.config` file, located in the ProjectGit repository.
|
||||
|
||||
See `ReviewGroups` in the [workflow-pr configuration](../workflow-pr/README.md#config-file).
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
"ReviewGroups": [
|
||||
{
|
||||
"Name": "name of the group user",
|
||||
"Reviewers": ["members", "of", "group"],
|
||||
"Silent": "(true, false) -- if true, do not explicitly require review requests of group members"
|
||||
}
|
||||
],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Server configuration
|
||||
--------------------------
|
||||
|
||||
**Configuration file:**
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ----- | ----- | ----- |
|
||||
| root | Array of string | Format **org/repo\#branch** |
|
||||
|
||||
Requirements
|
||||
------------
|
||||
* Gitea token to:
|
||||
+ R/W PullRequest
|
||||
+ R/W Notification
|
||||
+ R User
|
||||
Gitea token with following permissions:
|
||||
- R/W PullRequest
|
||||
- R/W Notification
|
||||
- R User
|
||||
|
||||
Env Variables
|
||||
-------------
|
||||
The following variables can be used (and override) command line parameters.
|
||||
|
||||
* `AUTOGITS_CONFIG` - config file location
|
||||
* `AUTOGITS_URL` - Gitea URL
|
||||
* `AUTOGITS_RABBITURL` - RabbitMQ url
|
||||
* `AUTOGITS_DEBUG` - when set, debug level logging enabled
|
||||
|
||||
Authentication env variables
|
||||
* `GITEA_TOKEN` - Gitea user token
|
||||
* `AMQP_USERNAME`, `AMQP_PASSWORD` - username and password for rabbitmq
|
||||
|
||||
|
||||
@@ -5,52 +5,69 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
"src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
)
|
||||
|
||||
var configs common.AutogitConfigs
|
||||
var acceptRx *regexp.Regexp
|
||||
var rejectRx *regexp.Regexp
|
||||
var groupName string
|
||||
|
||||
func InitRegex(groupName string) {
|
||||
acceptRx = regexp.MustCompile("\\s*:\\s*LGTM")
|
||||
rejectRx = regexp.MustCompile("\\s*:\\s*")
|
||||
type ReviewBot struct {
|
||||
configs common.AutogitConfigs
|
||||
acceptRx *regexp.Regexp
|
||||
rejectRx *regexp.Regexp
|
||||
groupName string
|
||||
gitea common.Gitea
|
||||
}
|
||||
|
||||
func ParseReviewLine(reviewText string) (bool, string) {
|
||||
func (bot *ReviewBot) InitRegex(newGroupName string) {
|
||||
bot.groupName = newGroupName
|
||||
bot.acceptRx = regexp.MustCompile("^:\\s*(LGTM|approved?)")
|
||||
bot.rejectRx = regexp.MustCompile("^:\\s*")
|
||||
}
|
||||
|
||||
func (bot *ReviewBot) ParseReviewLine(reviewText string) (bool, string) {
|
||||
line := strings.TrimSpace(reviewText)
|
||||
groupTextName := "@" + groupName
|
||||
groupTextName := "@" + bot.groupName
|
||||
glen := len(groupTextName)
|
||||
if len(line) < glen || line[0:glen] != groupTextName {
|
||||
return false, line
|
||||
}
|
||||
|
||||
return true, line[glen:]
|
||||
l := line[glen:]
|
||||
for idx, r := range l {
|
||||
if unicode.IsSpace(r) {
|
||||
continue
|
||||
} else if r == ':' {
|
||||
return true, l[idx:]
|
||||
} else {
|
||||
return false, line
|
||||
}
|
||||
}
|
||||
|
||||
return false, line
|
||||
}
|
||||
|
||||
func ReviewAccepted(reviewText string) bool {
|
||||
func (bot *ReviewBot) ReviewAccepted(reviewText string) bool {
|
||||
for _, line := range common.SplitStringNoEmpty(reviewText, "\n") {
|
||||
if matched, reviewLine := ParseReviewLine(line); matched {
|
||||
return acceptRx.MatchString(reviewLine)
|
||||
if matched, reviewLine := bot.ParseReviewLine(line); matched {
|
||||
return bot.acceptRx.MatchString(reviewLine)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ReviewRejected(reviewText string) bool {
|
||||
func (bot *ReviewBot) ReviewRejected(reviewText string) bool {
|
||||
for _, line := range common.SplitStringNoEmpty(reviewText, "\n") {
|
||||
if matched, reviewLine := ParseReviewLine(line); matched {
|
||||
if rejectRx.MatchString(reviewLine) {
|
||||
return !acceptRx.MatchString(reviewLine)
|
||||
if matched, reviewLine := bot.ParseReviewLine(line); matched {
|
||||
if bot.rejectRx.MatchString(reviewLine) {
|
||||
return !bot.acceptRx.MatchString(reviewLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,10 +117,10 @@ var commentStrings = []string{
|
||||
"change_time_estimate",
|
||||
}*/
|
||||
|
||||
func FindAcceptableReviewInTimeline(user string, timeline []*models.TimelineComment, reviews []*models.PullReview) *models.TimelineComment {
|
||||
func (bot *ReviewBot) FindAcceptableReviewInTimeline(user string, timeline []*models.TimelineComment, reviews []*models.PullReview) *models.TimelineComment {
|
||||
for _, t := range timeline {
|
||||
if t.Type == common.TimelineCommentType_Comment && t.User.UserName == user && t.Created == t.Updated {
|
||||
if ReviewAccepted(t.Body) || ReviewRejected(t.Body) {
|
||||
if bot.ReviewAccepted(t.Body) || bot.ReviewRejected(t.Body) {
|
||||
return t
|
||||
}
|
||||
}
|
||||
@@ -112,13 +129,23 @@ func FindAcceptableReviewInTimeline(user string, timeline []*models.TimelineComm
|
||||
return nil
|
||||
}
|
||||
|
||||
func UnrequestReviews(gitea common.Gitea, org, repo string, id int64, users []string) {
|
||||
if err := gitea.UnrequestReview(org, repo, id, users...); err != nil {
|
||||
func (bot *ReviewBot) FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.TimelineComment {
|
||||
for _, t := range timeline {
|
||||
if t.Type == common.TimelineCommentType_Review && t.User.UserName == bot.groupName && t.Created == t.Updated {
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bot *ReviewBot) UnrequestReviews(org, repo string, id int64, users []string) {
|
||||
if err := bot.gitea.UnrequestReview(org, repo, id, users...); err != nil {
|
||||
common.LogError("Can't remove reviewrs after a review:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ProcessNotifications(notification *models.NotificationThread, gitea common.Gitea) {
|
||||
func (bot *ReviewBot) ProcessNotifications(notification *models.NotificationThread) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
common.LogInfo("panic cought --- recovered")
|
||||
@@ -126,7 +153,7 @@ func ProcessNotifications(notification *models.NotificationThread, gitea common.
|
||||
}
|
||||
}()
|
||||
|
||||
rx := regexp.MustCompile(`^/?api/v\d+/repos/(?<org>[_a-zA-Z0-9-]+)/(?<project>[_a-zA-Z0-9-]+)/(?:issues|pulls)/(?<num>[0-9]+)$`)
|
||||
rx := regexp.MustCompile(`^/?api/v\d+/repos/(?<org>[_\.a-zA-Z0-9-]+)/(?<project>[_\.a-zA-Z0-9-]+)/(?:issues|pulls)/(?<num>[0-9]+)$`)
|
||||
subject := notification.Subject
|
||||
u, err := url.Parse(notification.Subject.URL)
|
||||
if err != nil {
|
||||
@@ -144,99 +171,110 @@ func ProcessNotifications(notification *models.NotificationThread, gitea common.
|
||||
repo := match[2]
|
||||
id, _ := strconv.ParseInt(match[3], 10, 64)
|
||||
|
||||
common.LogInfo("processing:", fmt.Sprintf("%s/%s#%d", org, repo, id))
|
||||
pr, err := gitea.GetPullRequest(org, repo, id)
|
||||
common.LogInfo("processing:", fmt.Sprintf("%s/%s!%d", org, repo, id))
|
||||
pr, err := bot.gitea.GetPullRequest(org, repo, id)
|
||||
if err != nil {
|
||||
common.LogError(" ** Cannot fetch PR associated with review:", subject.URL, "Error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bot.ProcessPR(pr); err == nil && !common.IsDryRun {
|
||||
if err := bot.gitea.SetNotificationRead(notification.ID); err != nil {
|
||||
common.LogDebug(" Cannot set notification as read", err)
|
||||
}
|
||||
} else if err != nil && err != ReviewNotFinished {
|
||||
common.LogError(err)
|
||||
}
|
||||
}
|
||||
|
||||
var ReviewNotFinished = fmt.Errorf("Review is not finished")
|
||||
|
||||
func (bot *ReviewBot) ProcessPR(pr *models.PullRequest) error {
|
||||
org := pr.Base.Repo.Owner.UserName
|
||||
repo := pr.Base.Repo.Name
|
||||
id := pr.Index
|
||||
|
||||
found := false
|
||||
for _, reviewer := range pr.RequestedReviewers {
|
||||
if reviewer != nil && reviewer.UserName == groupName {
|
||||
if reviewer != nil && reviewer.UserName == bot.groupName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
common.LogInfo(" review is not requested for", groupName)
|
||||
if !common.IsDryRun {
|
||||
gitea.SetNotificationRead(notification.ID)
|
||||
}
|
||||
return
|
||||
common.LogInfo(" review is not requested for", bot.groupName)
|
||||
return nil
|
||||
}
|
||||
|
||||
config := configs.GetPrjGitConfig(org, repo, pr.Base.Name)
|
||||
config := bot.configs.GetPrjGitConfig(org, repo, pr.Base.Name)
|
||||
if config == nil {
|
||||
common.LogError("Cannot find config for:", fmt.Sprintf("%s/%s#%s", org, repo, pr.Base.Name))
|
||||
return
|
||||
return fmt.Errorf("Cannot find config for: %s", pr.URL)
|
||||
}
|
||||
if pr.State == "closed" {
|
||||
// dismiss the review
|
||||
common.LogInfo(" -- closed request, so nothing to review")
|
||||
if !common.IsDryRun {
|
||||
gitea.SetNotificationRead(notification.ID)
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
reviews, err := gitea.GetPullRequestReviews(org, repo, id)
|
||||
reviews, err := bot.gitea.GetPullRequestReviews(org, repo, id)
|
||||
if err != nil {
|
||||
common.LogInfo(" ** No reviews associated with request:", subject.URL, "Error:", err)
|
||||
return
|
||||
return fmt.Errorf("Failed to fetch reviews for: %v: %w", pr.URL, err)
|
||||
}
|
||||
|
||||
timeline, err := common.FetchTimelineSinceReviewRequestOrPush(gitea, groupName, pr.Head.Sha, org, repo, id)
|
||||
timeline, err := common.FetchTimelineSinceReviewRequestOrPush(bot.gitea, bot.groupName, pr.Head.Sha, org, repo, id)
|
||||
if err != nil {
|
||||
common.LogError(err)
|
||||
return
|
||||
return fmt.Errorf("Failed to fetch timeline to review. %w", err)
|
||||
}
|
||||
|
||||
requestReviewers, err := config.GetReviewGroupMembers(groupName)
|
||||
groupConfig, err := config.GetReviewGroup(bot.groupName)
|
||||
if err != nil {
|
||||
common.LogError(err)
|
||||
return
|
||||
return fmt.Errorf("Failed to fetch review group. %w", err)
|
||||
}
|
||||
|
||||
// submitter cannot be reviewer
|
||||
requestReviewers := slices.Clone(groupConfig.Reviewers)
|
||||
requestReviewers = slices.DeleteFunc(requestReviewers, func(u string) bool { return u == pr.User.UserName })
|
||||
// pr.Head.Sha
|
||||
|
||||
for _, reviewer := range requestReviewers {
|
||||
if review := FindAcceptableReviewInTimeline(reviewer, timeline, reviews); review != nil {
|
||||
if ReviewAccepted(review.Body) {
|
||||
if review := bot.FindAcceptableReviewInTimeline(reviewer, timeline, reviews); review != nil {
|
||||
if bot.ReviewAccepted(review.Body) {
|
||||
if !common.IsDryRun {
|
||||
gitea.AddReviewComment(pr, common.ReviewStateApproved, "Signed off by: "+reviewer)
|
||||
UnrequestReviews(gitea, org, repo, id, requestReviewers)
|
||||
if !common.IsDryRun {
|
||||
if err := gitea.SetNotificationRead(notification.ID); err != nil {
|
||||
common.LogDebug(" Cannot set notification as read", err)
|
||||
text := reviewer + " approved a review on behalf of " + bot.groupName
|
||||
if review := bot.FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
|
||||
_, err := bot.gitea.AddReviewComment(pr, common.ReviewStateApproved, text)
|
||||
if err != nil {
|
||||
common.LogError(" -> failed to write approval comment", err)
|
||||
}
|
||||
bot.UnrequestReviews(org, repo, id, requestReviewers)
|
||||
}
|
||||
}
|
||||
common.LogInfo(" -> approved by", reviewer)
|
||||
common.LogInfo(" review at", review.Created)
|
||||
return
|
||||
} else if ReviewRejected(review.Body) {
|
||||
return nil
|
||||
} else if bot.ReviewRejected(review.Body) {
|
||||
if !common.IsDryRun {
|
||||
gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Changes requested. See review by: "+reviewer)
|
||||
UnrequestReviews(gitea, org, repo, id, requestReviewers)
|
||||
if err := gitea.SetNotificationRead(notification.ID); err != nil {
|
||||
common.LogDebug(" Cannot set notification as read", err)
|
||||
text := reviewer + " requested changes on behalf of " + bot.groupName + ". See " + review.HTMLURL
|
||||
if review := bot.FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
|
||||
_, err := bot.gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, text)
|
||||
if err != nil {
|
||||
common.LogError(" -> failed to write rejecting comment", err)
|
||||
}
|
||||
bot.UnrequestReviews(org, repo, id, requestReviewers)
|
||||
}
|
||||
}
|
||||
common.LogInfo(" -> declined by", reviewer)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// request group member reviews, if missing
|
||||
common.LogDebug(" Review incomplete...")
|
||||
if len(requestReviewers) > 0 {
|
||||
if !groupConfig.Silent && len(requestReviewers) > 0 {
|
||||
common.LogDebug(" Requesting reviews for:", requestReviewers)
|
||||
if !common.IsDryRun {
|
||||
if _, err := gitea.RequestReviews(pr, requestReviewers...); err != nil {
|
||||
if _, err := bot.gitea.RequestReviews(pr, requestReviewers...); err != nil {
|
||||
common.LogDebug(" -> err:", err)
|
||||
}
|
||||
} else {
|
||||
@@ -249,40 +287,67 @@ func ProcessNotifications(notification *models.NotificationThread, gitea common.
|
||||
// add a helpful comment, if not yet added
|
||||
found_help_comment := false
|
||||
for _, t := range timeline {
|
||||
if t.Type == common.TimelineCommentType_Comment && t.User != nil && t.User.UserName == groupName {
|
||||
if t.Type == common.TimelineCommentType_Comment && t.User != nil && t.User.UserName == bot.groupName {
|
||||
found_help_comment = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found_help_comment && !common.IsDryRun {
|
||||
helpComment := fmt.Sprintln("Review by", groupName, "represents a group of reviewers:", strings.Join(requestReviewers, ", "), ". To review as part of this group, create a comment with contents @"+groupName+": LGTM on a separate line to accept a review. To request changes, write @"+groupName+": followed by reason for rejection. Do not use reviews to review as a group. Editing a comment invalidates that comment.")
|
||||
gitea.AddComment(pr, helpComment)
|
||||
helpComment := fmt.Sprintln("Review by", bot.groupName, "represents a group of reviewers:", strings.Join(requestReviewers, ", "), ".\n\n"+
|
||||
"Do **not** use standard review interface to review on behalf of the group.\n"+
|
||||
"To accept the review on behalf of the group, create the following comment: `@"+bot.groupName+": approve`.\n"+
|
||||
"To request changes on behalf of the group, create the following comment: `@"+bot.groupName+": decline` followed with lines justifying the decision.\n"+
|
||||
"Future edits of the comments are ignored, a new comment is required to change the review state.")
|
||||
if slices.Contains(groupConfig.Reviewers, pr.User.UserName) {
|
||||
helpComment = helpComment + "\n\n" +
|
||||
"Submitter is member of this review group, hence they are excluded from being one of the reviewers here"
|
||||
}
|
||||
bot.gitea.AddComment(pr, helpComment)
|
||||
}
|
||||
|
||||
return ReviewNotFinished
|
||||
}
|
||||
|
||||
func PeriodReviewCheck(gitea common.Gitea) {
|
||||
notifications, err := gitea.GetNotifications(common.GiteaNotificationType_Pull, nil)
|
||||
func (bot *ReviewBot) PeriodReviewCheck() {
|
||||
notifications, err := bot.gitea.GetNotifications(common.GiteaNotificationType_Pull, nil)
|
||||
if err != nil {
|
||||
common.LogError(" Error fetching unread notifications: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, notification := range notifications {
|
||||
ProcessNotifications(notification, gitea)
|
||||
|
||||
bot.ProcessNotifications(notification)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
giteaUrl := flag.String("gitea-url", "https://src.opensuse.org", "Gitea instance used for reviews")
|
||||
rabbitMqHost := flag.String("rabbit-url", "amqps://rabbit.opensuse.org", "RabbitMQ instance where Gitea webhook notifications are sent")
|
||||
interval := flag.Int64("interval", 5, "Notification polling interval in minutes (min 1 min)")
|
||||
interval := flag.Int64("interval", 10, "Notification polling interval in minutes (min 1 min)")
|
||||
configFile := flag.String("config", "", "PrjGit listing config file")
|
||||
logging := flag.String("logging", "info", "Logging level: [none, error, info, debug]")
|
||||
flag.BoolVar(&common.IsDryRun, "dry", false, "Dry run, no effect. For debugging")
|
||||
flag.Parse()
|
||||
|
||||
if err := common.SetLoggingLevelFromString(*logging); err != nil {
|
||||
common.LogError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if cf := os.Getenv("AUTOGITS_CONFIG"); len(cf) > 0 {
|
||||
*configFile = cf
|
||||
}
|
||||
if url := os.Getenv("AUTOGITS_URL"); len(url) > 0 {
|
||||
*giteaUrl = url
|
||||
}
|
||||
if url := os.Getenv("AUTOGITS_RABBITURL"); len(url) > 0 {
|
||||
*rabbitMqHost = url
|
||||
}
|
||||
if debug := os.Getenv("AUTOGITS_DEBUG"); len(debug) > 0 {
|
||||
common.SetLoggingLevel(common.LogLevelDebug)
|
||||
}
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) != 1 {
|
||||
log.Println(" syntax:")
|
||||
@@ -291,7 +356,7 @@ func main() {
|
||||
flag.Usage()
|
||||
return
|
||||
}
|
||||
groupName = args[0]
|
||||
targetGroupName := args[0]
|
||||
|
||||
if *configFile == "" {
|
||||
common.LogError("Missing config file")
|
||||
@@ -314,36 +379,35 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
gitea := common.AllocateGiteaTransport(*giteaUrl)
|
||||
configs, err = common.ResolveWorkflowConfigs(gitea, configData)
|
||||
giteaTransport := common.AllocateGiteaTransport(*giteaUrl)
|
||||
configs, err := common.ResolveWorkflowConfigs(giteaTransport, configData)
|
||||
if err != nil {
|
||||
common.LogError("Cannot parse workflow configs:", err)
|
||||
return
|
||||
}
|
||||
|
||||
reviewer, err := gitea.GetCurrentUser()
|
||||
reviewer, err := giteaTransport.GetCurrentUser()
|
||||
if err != nil {
|
||||
common.LogError("Cannot fetch review user:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.SetLoggingLevelFromString(*logging); err != nil {
|
||||
common.LogError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if *interval < 1 {
|
||||
*interval = 1
|
||||
}
|
||||
|
||||
InitRegex(groupName)
|
||||
bot := &ReviewBot{
|
||||
gitea: giteaTransport,
|
||||
configs: configs,
|
||||
}
|
||||
bot.InitRegex(targetGroupName)
|
||||
|
||||
common.LogInfo(" ** processing group reviews for group:", groupName)
|
||||
common.LogInfo(" ** processing group reviews for group:", bot.groupName)
|
||||
common.LogInfo(" ** username in Gitea:", reviewer.UserName)
|
||||
common.LogInfo(" ** polling interval:", *interval, "min")
|
||||
common.LogInfo(" ** connecting to RabbitMQ:", *rabbitMqHost)
|
||||
|
||||
if groupName != reviewer.UserName {
|
||||
if bot.groupName != reviewer.UserName {
|
||||
common.LogError(" ***** Reviewer does not match group name. Aborting. *****")
|
||||
return
|
||||
}
|
||||
@@ -355,22 +419,28 @@ func main() {
|
||||
}
|
||||
|
||||
config_update := ConfigUpdatePush{
|
||||
bot: bot,
|
||||
config_modified: make(chan *common.AutogitConfig),
|
||||
}
|
||||
|
||||
configUpdates := &common.ListenDefinitions{
|
||||
RabbitURL: u,
|
||||
Orgs: []string{},
|
||||
process_issue_pr := IssueCommentProcessor{
|
||||
bot: bot,
|
||||
}
|
||||
|
||||
configUpdates := &common.RabbitMQGiteaEventsProcessor{
|
||||
Orgs: []string{},
|
||||
Handlers: map[string]common.RequestProcessor{
|
||||
common.RequestType_Push: &config_update,
|
||||
common.RequestType_Push: &config_update,
|
||||
common.RequestType_IssueComment: &process_issue_pr,
|
||||
},
|
||||
}
|
||||
for _, c := range configs {
|
||||
configUpdates.Connection().RabbitURL = u
|
||||
for _, c := range bot.configs {
|
||||
if org, _, _ := c.GetPrjGit(); !slices.Contains(configUpdates.Orgs, org) {
|
||||
configUpdates.Orgs = append(configUpdates.Orgs, org)
|
||||
}
|
||||
}
|
||||
go configUpdates.ProcessRabbitMQEvents()
|
||||
go common.ProcessRabbitMQEvents(configUpdates)
|
||||
|
||||
for {
|
||||
config_update_loop:
|
||||
@@ -378,17 +448,17 @@ func main() {
|
||||
select {
|
||||
case configTouched, ok := <-config_update.config_modified:
|
||||
if ok {
|
||||
for idx, c := range configs {
|
||||
for idx, c := range bot.configs {
|
||||
if c == configTouched {
|
||||
org, repo, branch := c.GetPrjGit()
|
||||
prj := fmt.Sprintf("%s/%s#%s", org, repo, branch)
|
||||
common.LogInfo("Detected config update for", prj)
|
||||
|
||||
new_config, err := common.ReadWorkflowConfig(gitea, prj)
|
||||
new_config, err := common.ReadWorkflowConfig(bot.gitea, prj)
|
||||
if err != nil {
|
||||
common.LogError("Failed parsing Project config for", prj, err)
|
||||
} else {
|
||||
configs[idx] = new_config
|
||||
bot.configs[idx] = new_config
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,7 +468,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
PeriodReviewCheck(gitea)
|
||||
bot.PeriodReviewCheck()
|
||||
time.Sleep(time.Duration(*interval * int64(time.Minute)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,492 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
func TestReviews(t *testing.T) {
|
||||
"go.uber.org/mock/gomock"
|
||||
"src.opensuse.org/autogits/common"
|
||||
"src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
mock_common "src.opensuse.org/autogits/common/mock"
|
||||
)
|
||||
|
||||
func TestProcessPR(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockGitea := mock_common.NewMockGitea(ctrl)
|
||||
groupName := "testgroup"
|
||||
|
||||
bot := &ReviewBot{
|
||||
gitea: mockGitea,
|
||||
groupName: groupName,
|
||||
}
|
||||
bot.InitRegex(groupName)
|
||||
|
||||
org := "myorg"
|
||||
repo := "myrepo"
|
||||
prIndex := int64(1)
|
||||
headSha := "abcdef123456"
|
||||
|
||||
pr := &models.PullRequest{
|
||||
Index: prIndex,
|
||||
URL: "http://gitea/pr/1",
|
||||
State: "open",
|
||||
Base: &models.PRBranchInfo{
|
||||
Name: "main",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{
|
||||
UserName: org,
|
||||
},
|
||||
},
|
||||
},
|
||||
Head: &models.PRBranchInfo{
|
||||
Sha: headSha,
|
||||
},
|
||||
User: &models.User{
|
||||
UserName: "submitter",
|
||||
},
|
||||
RequestedReviewers: []*models.User{
|
||||
{UserName: groupName},
|
||||
},
|
||||
}
|
||||
|
||||
prjConfig := &common.AutogitConfig{
|
||||
GitProjectName: org + "/" + repo + "#main",
|
||||
ReviewGroups: []*common.ReviewGroup{
|
||||
{
|
||||
Name: groupName,
|
||||
Reviewers: []string{"reviewer1", "reviewer2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
|
||||
t.Run("Review not requested for group", func(t *testing.T) {
|
||||
prNoRequest := *pr
|
||||
prNoRequest.RequestedReviewers = nil
|
||||
err := bot.ProcessPR(&prNoRequest)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PR is closed", func(t *testing.T) {
|
||||
prClosed := *pr
|
||||
prClosed.State = "closed"
|
||||
err := bot.ProcessPR(&prClosed)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Successful Approval", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
// reviewer1 approved in timeline
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer1"},
|
||||
Body: "@" + groupName + ": approve",
|
||||
},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
expectedText := "reviewer1 approved a review on behalf of " + groupName
|
||||
mockGitea.EXPECT().AddReviewComment(pr, common.ReviewStateApproved, expectedText).Return(nil, nil)
|
||||
mockGitea.EXPECT().UnrequestReview(org, repo, prIndex, gomock.Any()).Return(nil)
|
||||
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Dry Run - No actions taken", func(t *testing.T) {
|
||||
common.IsDryRun = true
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer1"},
|
||||
Body: "@" + groupName + ": approve",
|
||||
},
|
||||
}
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
// No AddReviewComment or UnrequestReview should be called
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Approval already exists - No new comment", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
|
||||
approvalText := "reviewer1 approved a review on behalf of " + groupName
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Review,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: approvalText,
|
||||
},
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer1"},
|
||||
Body: "@" + groupName + ": approve",
|
||||
},
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: "Help comment",
|
||||
},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
// No AddReviewComment, UnrequestReview, or AddComment should be called
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Rejection already exists - No new comment", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
|
||||
rejectionText := "reviewer1 requested changes on behalf of " + groupName + ". See http://gitea/comment/123"
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Review,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: rejectionText,
|
||||
},
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer1"},
|
||||
Body: "@" + groupName + ": decline",
|
||||
HTMLURL: "http://gitea/comment/123",
|
||||
},
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: "Help comment",
|
||||
},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Pending review - Help comment already exists", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: groupName},
|
||||
Body: "Some help comment",
|
||||
},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
|
||||
// It will try to request reviews
|
||||
mockGitea.EXPECT().RequestReviews(pr, "reviewer1", "reviewer2").Return(nil, nil)
|
||||
|
||||
// AddComment should NOT be called because bot already has a comment in timeline
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != ReviewNotFinished {
|
||||
t.Errorf("Expected ReviewNotFinished error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Submitter is group member - Excluded from review request", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
prSubmitterMember := *pr
|
||||
prSubmitterMember.User = &models.User{UserName: "reviewer1"}
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(nil, nil)
|
||||
mockGitea.EXPECT().RequestReviews(&prSubmitterMember, "reviewer2").Return(nil, nil)
|
||||
mockGitea.EXPECT().AddComment(&prSubmitterMember, gomock.Any()).Return(nil)
|
||||
err := bot.ProcessPR(&prSubmitterMember)
|
||||
if err != ReviewNotFinished {
|
||||
t.Errorf("Expected ReviewNotFinished error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Successful Rejection", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "reviewer2"},
|
||||
Body: "@" + groupName + ": decline",
|
||||
HTMLURL: "http://gitea/comment/999",
|
||||
},
|
||||
}
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
expectedText := "reviewer2 requested changes on behalf of " + groupName + ". See http://gitea/comment/999"
|
||||
mockGitea.EXPECT().AddReviewComment(pr, common.ReviewStateRequestChanges, expectedText).Return(nil, nil)
|
||||
mockGitea.EXPECT().UnrequestReview(org, repo, prIndex, gomock.Any()).Return(nil)
|
||||
err := bot.ProcessPR(pr)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Config not found", func(t *testing.T) {
|
||||
bot.configs = common.AutogitConfigs{}
|
||||
err := bot.ProcessPR(pr)
|
||||
if err == nil {
|
||||
t.Error("Expected error when config is missing, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Gitea error in GetPullRequestReviews", func(t *testing.T) {
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, fmt.Errorf("gitea error"))
|
||||
err := bot.ProcessPR(pr)
|
||||
if err == nil {
|
||||
t.Error("Expected error from gitea, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessNotifications(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockGitea := mock_common.NewMockGitea(ctrl)
|
||||
groupName := "testgroup"
|
||||
|
||||
bot := &ReviewBot{
|
||||
gitea: mockGitea,
|
||||
groupName: groupName,
|
||||
}
|
||||
bot.InitRegex(groupName)
|
||||
|
||||
org := "myorg"
|
||||
repo := "myrepo"
|
||||
prIndex := int64(123)
|
||||
notificationID := int64(456)
|
||||
|
||||
notification := &models.NotificationThread{
|
||||
ID: notificationID,
|
||||
Subject: &models.NotificationSubject{
|
||||
URL: fmt.Sprintf("http://gitea/api/v1/repos/%s/%s/pulls/%d", org, repo, prIndex),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Notification Success", func(t *testing.T) {
|
||||
common.IsDryRun = false
|
||||
pr := &models.PullRequest{
|
||||
Index: prIndex,
|
||||
Base: &models.PRBranchInfo{
|
||||
Name: "main",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{UserName: org},
|
||||
},
|
||||
},
|
||||
|
||||
Head: &models.PRBranchInfo{
|
||||
Sha: "headsha",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{UserName: org},
|
||||
},
|
||||
},
|
||||
|
||||
User: &models.User{UserName: "submitter"},
|
||||
RequestedReviewers: []*models.User{{UserName: groupName}},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetPullRequest(org, repo, prIndex).Return(pr, nil)
|
||||
|
||||
prjConfig := &common.AutogitConfig{
|
||||
GitProjectName: org + "/" + repo + "#main",
|
||||
ReviewGroups: []*common.ReviewGroup{{Name: groupName, Reviewers: []string{"r1"}}},
|
||||
}
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil)
|
||||
timeline := []*models.TimelineComment{
|
||||
{
|
||||
Type: common.TimelineCommentType_Comment,
|
||||
User: &models.User{UserName: "r1"},
|
||||
Body: "@" + groupName + ": approve",
|
||||
},
|
||||
}
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil)
|
||||
expectedText := "r1 approved a review on behalf of " + groupName
|
||||
mockGitea.EXPECT().AddReviewComment(pr, common.ReviewStateApproved, expectedText).Return(nil, nil)
|
||||
mockGitea.EXPECT().UnrequestReview(org, repo, prIndex, gomock.Any()).Return(nil)
|
||||
|
||||
mockGitea.EXPECT().SetNotificationRead(notificationID).Return(nil)
|
||||
|
||||
bot.ProcessNotifications(notification)
|
||||
|
||||
})
|
||||
|
||||
t.Run("Invalid Notification URL", func(t *testing.T) {
|
||||
badNotification := &models.NotificationThread{
|
||||
Subject: &models.NotificationSubject{
|
||||
URL: "http://gitea/invalid/url",
|
||||
},
|
||||
}
|
||||
bot.ProcessNotifications(badNotification)
|
||||
})
|
||||
|
||||
t.Run("Gitea error in GetPullRequest", func(t *testing.T) {
|
||||
mockGitea.EXPECT().GetPullRequest(org, repo, prIndex).Return(nil, fmt.Errorf("gitea error"))
|
||||
bot.ProcessNotifications(notification)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReviewApprovalCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
Name string
|
||||
GroupName string
|
||||
InString string
|
||||
Approved bool
|
||||
Rejected bool
|
||||
}{
|
||||
{
|
||||
Name: "Empty String",
|
||||
GroupName: "group",
|
||||
InString: "",
|
||||
},
|
||||
{
|
||||
Name: "Random Text",
|
||||
GroupName: "group",
|
||||
InString: "some things LGTM",
|
||||
},
|
||||
{
|
||||
Name: "Group name with Random Text means disapproval",
|
||||
GroupName: "group",
|
||||
InString: "@group: some things LGTM",
|
||||
Rejected: true,
|
||||
},
|
||||
{
|
||||
Name: "Bad name with Approval",
|
||||
GroupName: "group2",
|
||||
InString: "@group: LGTM",
|
||||
},
|
||||
{
|
||||
Name: "Bad name with Approval",
|
||||
GroupName: "group2",
|
||||
InString: "@group: LGTM",
|
||||
},
|
||||
{
|
||||
Name: "LGTM approval",
|
||||
GroupName: "group2",
|
||||
InString: "@group2: LGTM",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "approval",
|
||||
GroupName: "group2",
|
||||
InString: "@group2: approved",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "approval",
|
||||
GroupName: "group2",
|
||||
InString: "@group2: approve",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "disapproval",
|
||||
GroupName: "group2",
|
||||
InString: "@group2: disapprove",
|
||||
Rejected: true,
|
||||
},
|
||||
{
|
||||
Name: "Whitespace before colon",
|
||||
GroupName: "group",
|
||||
InString: "@group : LGTM",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "No whitespace after colon",
|
||||
GroupName: "group",
|
||||
InString: "@group:LGTM",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "Leading and trailing whitespace on line",
|
||||
GroupName: "group",
|
||||
InString: " @group: LGTM ",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "Multiline: Approved on second line",
|
||||
GroupName: "group",
|
||||
InString: "Random noise\n@group: approved",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "Multiline: Multiple group mentions, first wins",
|
||||
GroupName: "group",
|
||||
InString: "@group: decline\n@group: approve",
|
||||
Rejected: true,
|
||||
},
|
||||
{
|
||||
Name: "Multiline: Approved on second line",
|
||||
GroupName: "group",
|
||||
InString: "noise\n@group: approve\nmore noise",
|
||||
Approved: true,
|
||||
},
|
||||
{
|
||||
Name: "Not at start of line (even with whitespace)",
|
||||
GroupName: "group",
|
||||
InString: "Hello @group: approve",
|
||||
Approved: false,
|
||||
},
|
||||
{
|
||||
Name: "Rejecting with reason",
|
||||
GroupName: "group",
|
||||
InString: "@group: decline because of X, Y and Z",
|
||||
Rejected: true,
|
||||
},
|
||||
{
|
||||
Name: "No colon after group",
|
||||
GroupName: "group",
|
||||
InString: "@group LGTM",
|
||||
Approved: false,
|
||||
Rejected: false,
|
||||
},
|
||||
{
|
||||
Name: "Invalid char after group",
|
||||
GroupName: "group",
|
||||
InString: "@group! LGTM",
|
||||
Approved: false,
|
||||
Rejected: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
bot := &ReviewBot{}
|
||||
bot.InitRegex(test.GroupName)
|
||||
|
||||
if r := bot.ReviewAccepted(test.InString); r != test.Approved {
|
||||
t.Error("ReviewAccepted() returned", r, "expecting", test.Approved)
|
||||
}
|
||||
if r := bot.ReviewRejected(test.InString); r != test.Rejected {
|
||||
t.Error("ReviewRejected() returned", r, "expecting", test.Rejected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,29 @@ import (
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
type IssueCommentProcessor struct {
|
||||
bot *ReviewBot
|
||||
}
|
||||
|
||||
func (s *IssueCommentProcessor) ProcessFunc(req *common.Request) error {
|
||||
if req.Type != common.RequestType_IssueComment {
|
||||
return fmt.Errorf("Unhandled, ignored request type: %s", req.Type)
|
||||
}
|
||||
|
||||
data := req.Data.(*common.IssueCommentWebhookEvent)
|
||||
org := data.Repository.Owner.Username
|
||||
repo := data.Repository.Name
|
||||
index := int64(data.Issue.Number)
|
||||
|
||||
pr, err := s.bot.gitea.GetPullRequest(org, repo, index)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to fetch PullRequest from event: %s/%s!%d Error: %w", org, repo, index, err)
|
||||
}
|
||||
return s.bot.ProcessPR(pr)
|
||||
}
|
||||
|
||||
type ConfigUpdatePush struct {
|
||||
bot *ReviewBot
|
||||
config_modified chan *common.AutogitConfig
|
||||
}
|
||||
|
||||
@@ -27,7 +49,7 @@ func (s *ConfigUpdatePush) ProcessFunc(req *common.Request) error {
|
||||
}
|
||||
branch := data.Ref[len(branch_ref):]
|
||||
|
||||
c := configs.GetPrjGitConfig(org, repo, branch)
|
||||
c := s.bot.configs.GetPrjGitConfig(org, repo, branch)
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -45,7 +67,7 @@ func (s *ConfigUpdatePush) ProcessFunc(req *common.Request) error {
|
||||
}
|
||||
|
||||
if modified_config {
|
||||
for _, config := range configs {
|
||||
for _, config := range s.bot.configs {
|
||||
if o, r, _ := config.GetPrjGit(); o == org && r == repo {
|
||||
s.config_modified <- config
|
||||
}
|
||||
|
||||
203
group-review/rabbit_test.go
Normal file
203
group-review/rabbit_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"src.opensuse.org/autogits/common"
|
||||
"src.opensuse.org/autogits/common/gitea-generated/models"
|
||||
mock_common "src.opensuse.org/autogits/common/mock"
|
||||
)
|
||||
|
||||
func TestIssueCommentProcessor(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mockGitea := mock_common.NewMockGitea(ctrl)
|
||||
groupName := "testgroup"
|
||||
bot := &ReviewBot{
|
||||
gitea: mockGitea,
|
||||
groupName: groupName,
|
||||
}
|
||||
bot.InitRegex(groupName)
|
||||
|
||||
processor := &IssueCommentProcessor{bot: bot}
|
||||
|
||||
org := "myorg"
|
||||
repo := "myrepo"
|
||||
index := 123
|
||||
|
||||
event := &common.IssueCommentWebhookEvent{
|
||||
Repository: &common.Repository{
|
||||
Name: repo,
|
||||
Owner: &common.Organization{
|
||||
Username: org,
|
||||
},
|
||||
},
|
||||
Issue: &common.IssueDetail{
|
||||
Number: index,
|
||||
},
|
||||
}
|
||||
|
||||
req := &common.Request{
|
||||
Type: common.RequestType_IssueComment,
|
||||
Data: event,
|
||||
}
|
||||
|
||||
t.Run("Successful Processing", func(t *testing.T) {
|
||||
pr := &models.PullRequest{
|
||||
Index: int64(index),
|
||||
Base: &models.PRBranchInfo{
|
||||
Name: "main",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{UserName: org},
|
||||
},
|
||||
},
|
||||
Head: &models.PRBranchInfo{
|
||||
Sha: "headsha",
|
||||
Repo: &models.Repository{
|
||||
Name: repo,
|
||||
Owner: &models.User{UserName: org},
|
||||
},
|
||||
},
|
||||
User: &models.User{UserName: "submitter"},
|
||||
RequestedReviewers: []*models.User{{UserName: groupName}},
|
||||
}
|
||||
|
||||
mockGitea.EXPECT().GetPullRequest(org, repo, int64(index)).Return(pr, nil)
|
||||
|
||||
prjConfig := &common.AutogitConfig{
|
||||
GitProjectName: org + "/" + repo + "#main",
|
||||
ReviewGroups: []*common.ReviewGroup{{Name: groupName, Reviewers: []string{"r1"}}},
|
||||
}
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
mockGitea.EXPECT().GetPullRequestReviews(org, repo, int64(index)).Return(nil, nil)
|
||||
mockGitea.EXPECT().GetTimeline(org, repo, int64(index)).Return(nil, nil)
|
||||
mockGitea.EXPECT().RequestReviews(pr, "r1").Return(nil, nil)
|
||||
mockGitea.EXPECT().AddComment(pr, gomock.Any()).Return(nil)
|
||||
|
||||
err := processor.ProcessFunc(req)
|
||||
if err != ReviewNotFinished {
|
||||
t.Errorf("Expected ReviewNotFinished, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Gitea error in GetPullRequest", func(t *testing.T) {
|
||||
mockGitea.EXPECT().GetPullRequest(org, repo, int64(index)).Return(nil, fmt.Errorf("gitea error"))
|
||||
err := processor.ProcessFunc(req)
|
||||
if err == nil {
|
||||
t.Error("Expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong Request Type", func(t *testing.T) {
|
||||
wrongReq := &common.Request{Type: common.RequestType_Push}
|
||||
err := processor.ProcessFunc(wrongReq)
|
||||
if err == nil {
|
||||
t.Error("Expected error for wrong request type, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigUpdatePush(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
groupName := "testgroup"
|
||||
bot := &ReviewBot{
|
||||
groupName: groupName,
|
||||
}
|
||||
bot.InitRegex(groupName)
|
||||
|
||||
configChan := make(chan *common.AutogitConfig, 1)
|
||||
processor := &ConfigUpdatePush{
|
||||
bot: bot,
|
||||
config_modified: configChan,
|
||||
}
|
||||
|
||||
org := "myorg"
|
||||
repo := "myrepo"
|
||||
branch := "main"
|
||||
|
||||
prjConfig := &common.AutogitConfig{
|
||||
GitProjectName: org + "/" + repo + "#" + branch,
|
||||
Organization: org,
|
||||
Branch: branch,
|
||||
}
|
||||
bot.configs = common.AutogitConfigs{prjConfig}
|
||||
|
||||
event := &common.PushWebhookEvent{
|
||||
Ref: "refs/heads/" + branch,
|
||||
Repository: &common.Repository{
|
||||
Name: repo,
|
||||
Owner: &common.Organization{
|
||||
Username: org,
|
||||
},
|
||||
},
|
||||
Commits: []common.Commit{
|
||||
{
|
||||
Modified: []string{common.ProjectConfigFile},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := &common.Request{
|
||||
Type: common.RequestType_Push,
|
||||
Data: event,
|
||||
}
|
||||
|
||||
t.Run("Config Modified", func(t *testing.T) {
|
||||
err := processor.ProcessFunc(req)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case modified := <-configChan:
|
||||
if modified != prjConfig {
|
||||
t.Errorf("Expected modified config to be %v, got %v", prjConfig, modified)
|
||||
}
|
||||
default:
|
||||
t.Error("Expected config modification signal, but none received")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No Config Modified", func(t *testing.T) {
|
||||
noConfigEvent := *event
|
||||
noConfigEvent.Commits = []common.Commit{{Modified: []string{"README.md"}}}
|
||||
noConfigReq := &common.Request{Type: common.RequestType_Push, Data: &noConfigEvent}
|
||||
|
||||
err := processor.ProcessFunc(noConfigReq)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-configChan:
|
||||
t.Error("Did not expect config modification signal")
|
||||
default:
|
||||
// Success
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong Branch Ref", func(t *testing.T) {
|
||||
wrongBranchEvent := *event
|
||||
wrongBranchEvent.Ref = "refs/tags/v1.0"
|
||||
wrongBranchReq := &common.Request{Type: common.RequestType_Push, Data: &wrongBranchEvent}
|
||||
|
||||
err := processor.ProcessFunc(wrongBranchReq)
|
||||
if err == nil {
|
||||
t.Error("Expected error for wrong branch ref, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Config Not Found", func(t *testing.T) {
|
||||
bot.configs = common.AutogitConfigs{}
|
||||
err := processor.ProcessFunc(req)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error even if config not found, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
7
obs-forward-bot/README.md
Normal file
7
obs-forward-bot/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
Purpose
|
||||
-------
|
||||
|
||||
Forwards PR as an OBS submit request when review requested.
|
||||
Accepts a request when OBS request is accepted.
|
||||
Rejects a request when OBS request is denied.
|
||||
|
||||
@@ -108,7 +108,7 @@ func ProcessNotification(notification *models.NotificationThread) {
|
||||
repo := match[2]
|
||||
id, _ := strconv.ParseInt(match[3], 10, 64)
|
||||
|
||||
common.LogInfo("processing:", fmt.Sprintf("%s/%s#%d", org, repo, id))
|
||||
common.LogInfo("processing:", fmt.Sprintf("%s/%s!%d", org, repo, id))
|
||||
pr, err := Gitea.GetPullRequest(org, repo, id)
|
||||
if err != nil {
|
||||
common.LogError(" ** Cannot fetch PR associated with review:", subject.URL, "Error:", err)
|
||||
|
||||
@@ -4,11 +4,15 @@ OBS Staging Bot
|
||||
Build a PR against a ProjectGit, if review is requested.
|
||||
|
||||
|
||||
Areas of Responsibility
|
||||
-----------------------
|
||||
Main Tasks
|
||||
----------
|
||||
|
||||
* Monitors Notification API in Gitea for review requests
|
||||
* Reviews Package build results in OBS for all changed packages in ProjectGit PR
|
||||
* A build in OBS is initiated when a review for this bot is requested.
|
||||
* The overall build status is reported:
|
||||
* Build successful
|
||||
* Build failed
|
||||
* It checks the build status only for the involved packages compared to the last state of the project for all architectures and all flavors.
|
||||
* It adds an svg with detailed building status.
|
||||
|
||||
|
||||
Target Usage
|
||||
@@ -16,3 +20,53 @@ Target Usage
|
||||
|
||||
Any project (devel, etc) that accepts PR and wants build results
|
||||
|
||||
|
||||
Configuration File
|
||||
------------------
|
||||
|
||||
Bot reads `staging.config` from the project git or the PR to the project git.
|
||||
It's a JSON file with following syntax:
|
||||
|
||||
```json
|
||||
{
|
||||
"ObsProject": "SUSE:SLFO:1.2",
|
||||
"StagingProject": "SUSE:SLFO:1.2:PullRequest",
|
||||
"QA": [
|
||||
{
|
||||
"Name": "SLES",
|
||||
"Origin": "SUSE:SLFO:Products:SLES:16.0",
|
||||
"BuildDisableRepos": ["product"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field name | Details | Mandatory | Type | Allowed Values | Default |
|
||||
| ----- | ----- | ----- | ----- | ----- | ----- |
|
||||
| *ObsProject* | Product OBS project. Builds in this project will be used to compare to builds based on sources from the PR. | yes | string | `[a-zA-Z0-9-_:]+` | |
|
||||
| *StagingProject* | Used both as base project and prefix for all OBS staging projects. Upon being added as a reviewer to a PrjGit PR, this bot automatically generates an OBS project named *StagingProject:<PR_Number>*. It must be a sub-project of the *ObsProject*. | yes | string | `[a-zA-Z0-9-_:]+` | |
|
||||
| *QA* | Crucial for generating a product build (such as an ISO or FTP tree) that incorporates the packages. | no | array of objects | | |
|
||||
| *QA > Name* | Suffix for the QA OBS staging project. The project is named *StagingProject:<PR_Number>:Name*. | no | string | | |
|
||||
| *QA > Origin* | OBS reference project | no | string | | |
|
||||
| *QA > BuildDisableRepos* | The names of OBS repositories to build-disable, if any. | no | array of strings | | [] |
|
||||
|
||||
|
||||
Details
|
||||
-------
|
||||
|
||||
* **OBS staging projects are deleted** when the relative PrjGit PR is closed or merged.
|
||||
|
||||
* **PrjGit PR - staging project**
|
||||
* The OBS staging project utilizes an **scmsync** tag, configured with the `onlybuild` flag, to exclusively build packages associated with this specific PrjGit PR.
|
||||
* The **build config** is inherited from the PrjGit PR config file (even if unchanged).
|
||||
* The **project meta** creates a standard repository following the StagingProject as a project path.
|
||||
* The base *StagingProject* has the macro **FromScratch:** set in its config, which prevents inheriting the configuration from the included project paths.
|
||||
* The bot copies the project maintainers from *StagingProject* to the specific staging project (*StagingProject:<PR_Number>*).
|
||||
* The bot reports “Build successful” only if the build is successful for all repositories and all architectures.
|
||||
|
||||
* **PrjGit PR - QA staging project**
|
||||
* The QA staging project is meant for building the product; the relative build config is inherited from the `QA > Origin` project.
|
||||
* In this case, the **scmsync** tag is inherited from the `QA > Origin` project.
|
||||
* It is desirable in some cases to avoid building some specific build service repositories when not needed. In this case, `QA > BuildDisableRepos` can be specified.
|
||||
These repositories would be disabled in the project meta when generating the QA project.
|
||||
|
||||
|
||||
@@ -47,15 +47,18 @@ const (
|
||||
Username = "autogits_obs_staging_bot"
|
||||
)
|
||||
|
||||
var GiteaToken string
|
||||
var runId uint
|
||||
|
||||
func FetchPrGit(git common.Git, pr *models.PullRequest) error {
|
||||
// clone PR head and base and return path
|
||||
cloneURL := pr.Head.Repo.CloneURL
|
||||
if GiteaUseSshClone {
|
||||
cloneURL = pr.Head.Repo.SSHURL
|
||||
}
|
||||
// clone PR head via base (target) repo
|
||||
cloneURL := pr.Base.Repo.CloneURL
|
||||
|
||||
// pass our token as user always
|
||||
user, err := url.Parse(cloneURL)
|
||||
common.PanicOnError(err)
|
||||
user.User = url.User(common.GetGiteaToken())
|
||||
cloneURL = user.String()
|
||||
|
||||
if _, err := os.Stat(path.Join(git.GetPath(), pr.Head.Sha)); os.IsNotExist(err) {
|
||||
common.PanicOnError(git.GitExec("", "clone", "--depth", "1", cloneURL, pr.Head.Sha))
|
||||
common.PanicOnError(git.GitExec(pr.Head.Sha, "fetch", "--depth", "1", "origin", pr.Head.Sha, pr.MergeBase))
|
||||
@@ -106,6 +109,11 @@ const (
|
||||
BuildStatusSummaryUnknown = 4
|
||||
)
|
||||
|
||||
type DisableFlag struct {
|
||||
XMLName string `xml:"disable"`
|
||||
Name string `xml:"repository,attr"`
|
||||
}
|
||||
|
||||
func ProcessBuildStatus(project, refProject *common.BuildResultList) BuildStatusSummary {
|
||||
if _, finished := refProject.BuildResultSummary(); !finished {
|
||||
common.LogDebug("refProject not finished building??")
|
||||
@@ -120,7 +128,7 @@ func ProcessBuildStatus(project, refProject *common.BuildResultList) BuildStatus
|
||||
// the repositories should be setup equally between the projects. We
|
||||
// need to verify that packages that are building in `refProject` are not
|
||||
// failing in the `project`
|
||||
BuildResultSorter := func(a, b common.BuildResult) int {
|
||||
BuildResultSorter := func(a, b *common.BuildResult) int {
|
||||
if c := strings.Compare(a.Repository, b.Repository); c != 0 {
|
||||
return c
|
||||
}
|
||||
@@ -136,7 +144,7 @@ func ProcessBuildStatus(project, refProject *common.BuildResultList) BuildStatus
|
||||
common.LogInfo("New package. Only need some success...")
|
||||
SomeSuccess := false
|
||||
for i := 0; i < len(project.Result); i++ {
|
||||
repoRes := &project.Result[i]
|
||||
repoRes := project.Result[i]
|
||||
repoResStatus, ok := common.ObsRepoStatusDetails[repoRes.Code]
|
||||
if !ok {
|
||||
common.LogDebug("cannot find code:", repoRes.Code)
|
||||
@@ -205,8 +213,8 @@ func ProcessBuildStatus(project, refProject *common.BuildResultList) BuildStatus
|
||||
return BuildStatusSummaryFailed
|
||||
}
|
||||
|
||||
func ProcessRepoBuildStatus(results, ref []common.PackageBuildStatus) (status BuildStatusSummary, SomeSuccess bool) {
|
||||
PackageBuildStatusSorter := func(a, b common.PackageBuildStatus) int {
|
||||
func ProcessRepoBuildStatus(results, ref []*common.PackageBuildStatus) (status BuildStatusSummary, SomeSuccess bool) {
|
||||
PackageBuildStatusSorter := func(a, b *common.PackageBuildStatus) int {
|
||||
return strings.Compare(a.Package, b.Package)
|
||||
}
|
||||
|
||||
@@ -263,7 +271,7 @@ func ProcessRepoBuildStatus(results, ref []common.PackageBuildStatus) (status Bu
|
||||
return BuildStatusSummarySuccess, SomeSuccess
|
||||
}
|
||||
|
||||
func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingPrj, buildPrj string) (*common.ProjectMeta, error) {
|
||||
func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingPrj, buildPrj string, stagingMasterPrj string) (*common.ProjectMeta, error) {
|
||||
common.LogDebug("repo content fetching ...")
|
||||
err := FetchPrGit(git, pr)
|
||||
if err != nil {
|
||||
@@ -289,7 +297,32 @@ func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullReque
|
||||
}
|
||||
}
|
||||
|
||||
meta, err := ObsClient.GetProjectMeta(buildPrj)
|
||||
// find modified directories and assume they are packages
|
||||
// TODO: use _manifest for this here
|
||||
headDirectories, err := git.GitDirectoryList(dir, pr.Head.Sha)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseDirectories, err := git.GitDirectoryList(dir, pr.MergeBase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for pkg, headOid := range headDirectories {
|
||||
if baseOid, exists := baseDirectories[pkg]; !exists || baseOid != headOid {
|
||||
modifiedOrNew = append(modifiedOrNew, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
common.LogDebug("Trying first staging master project: ", stagingMasterPrj)
|
||||
meta, err := ObsClient.GetProjectMeta(stagingMasterPrj)
|
||||
if err == nil {
|
||||
// success, so we use that staging master project as our build project
|
||||
buildPrj = stagingMasterPrj
|
||||
} else {
|
||||
common.LogInfo("error fetching project meta for ", stagingMasterPrj, ". Fall Back to ", buildPrj)
|
||||
meta, err = ObsClient.GetProjectMeta(buildPrj)
|
||||
}
|
||||
if err != nil {
|
||||
common.LogError("error fetching project meta for", buildPrj, ". Err:", err)
|
||||
return nil, err
|
||||
@@ -314,13 +347,13 @@ func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullReque
|
||||
urlPkg = append(urlPkg, "onlybuild="+url.QueryEscape(pkg))
|
||||
}
|
||||
meta.ScmSync = pr.Head.Repo.CloneURL + "?" + strings.Join(urlPkg, "&") + "#" + pr.Head.Sha
|
||||
if len(meta.ScmSync) >= 65535 {
|
||||
return nil, errors.New("Reached max amount of package changes per request")
|
||||
}
|
||||
meta.Title = fmt.Sprintf("PR#%d to %s", pr.Index, pr.Base.Name)
|
||||
// QE wants it published ... also we should not hardcode it here, since
|
||||
// it is configurable via the :PullRequest project
|
||||
// meta.PublicFlags = common.Flags{Contents: "<disable/>"}
|
||||
|
||||
meta.Groups = nil
|
||||
meta.Persons = nil
|
||||
// Untouched content are flags and involved users. These can be configured
|
||||
// via the staging project.
|
||||
|
||||
// set paths to parent project
|
||||
for idx, r := range meta.Repositories {
|
||||
@@ -349,7 +382,7 @@ func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullReque
|
||||
// stagingProject:$buildProject
|
||||
// ^- stagingProject:$buildProject:$subProjectName (based on templateProject)
|
||||
|
||||
func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingProject, templateProject, subProjectName string) error {
|
||||
func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingProject, templateProject, subProjectName string, buildDisableRepos []string) error {
|
||||
common.LogDebug("Setup QA sub projects")
|
||||
templateMeta, err := ObsClient.GetProjectMeta(templateProject)
|
||||
if err != nil {
|
||||
@@ -358,6 +391,42 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git
|
||||
}
|
||||
// patch baseMeta to become the new project
|
||||
templateMeta.Name = stagingProject + ":" + subProjectName
|
||||
// freeze tag for now
|
||||
if len(templateMeta.ScmSync) > 0 {
|
||||
repository, err := url.Parse(templateMeta.ScmSync)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
common.LogDebug("getting data for ", repository.EscapedPath())
|
||||
split := strings.Split(repository.EscapedPath(), "/")
|
||||
org, repo := split[1], split[2]
|
||||
|
||||
common.LogDebug("getting commit for ", org, " repo ", repo, " fragment ", repository.Fragment)
|
||||
branch, err := gitea.GetCommit(org, repo, repository.Fragment)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// set expanded commit url
|
||||
repository.Fragment = branch.SHA
|
||||
templateMeta.ScmSync = repository.String()
|
||||
common.LogDebug("Setting scmsync url to ", templateMeta.ScmSync)
|
||||
}
|
||||
// Build-disable repositories if asked
|
||||
if len(buildDisableRepos) > 0 {
|
||||
toDisable := make([]DisableFlag, len(buildDisableRepos))
|
||||
for idx, repositoryName := range buildDisableRepos {
|
||||
toDisable[idx] = DisableFlag{Name: repositoryName}
|
||||
}
|
||||
|
||||
output, err := xml.Marshal(toDisable)
|
||||
if err != nil {
|
||||
common.LogError("error while marshalling, skipping BuildDisableRepos: ", err)
|
||||
} else {
|
||||
templateMeta.BuildFlags.Contents += string(output)
|
||||
}
|
||||
}
|
||||
// Cleanup ReleaseTarget and modify affected path entries
|
||||
for idx, r := range templateMeta.Repositories {
|
||||
templateMeta.Repositories[idx].ReleaseTargets = nil
|
||||
@@ -414,7 +483,8 @@ func StartOrUpdateBuild(config *common.StagingConfig, git common.Git, gitea comm
|
||||
var state RequestModification = RequestModificationSourceChanged
|
||||
if meta == nil {
|
||||
// new build
|
||||
meta, err = GenerateObsPrjMeta(git, gitea, pr, obsPrProject, config.ObsProject)
|
||||
common.LogDebug(" Staging master:", config.StagingProject)
|
||||
meta, err = GenerateObsPrjMeta(git, gitea, pr, obsPrProject, config.ObsProject, config.StagingProject)
|
||||
if err != nil {
|
||||
return RequestModificationNoChange, err
|
||||
}
|
||||
@@ -428,6 +498,8 @@ func StartOrUpdateBuild(config *common.StagingConfig, git common.Git, gitea comm
|
||||
} else {
|
||||
err = ObsClient.SetProjectMeta(meta)
|
||||
if err != nil {
|
||||
x, _ := xml.MarshalIndent(meta, "", " ")
|
||||
common.LogDebug(" meta:", string(x))
|
||||
common.LogError("cannot create meta project:", err)
|
||||
return RequestModificationNoChange, err
|
||||
}
|
||||
@@ -584,7 +656,7 @@ func CleanupPullNotification(gitea common.Gitea, thread *models.NotificationThre
|
||||
}
|
||||
|
||||
if !pr.HasMerged && time.Since(time.Time(pr.Closed)) < time.Duration(config.CleanupDelay)*time.Hour {
|
||||
common.LogInfo("Cooldown period for cleanup of", thread.URL)
|
||||
common.LogInfo("Cooldown period for cleanup of", thread.Subject.HTMLURL)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -622,6 +694,14 @@ func CleanupPullNotification(gitea common.Gitea, thread *models.NotificationThre
|
||||
return false // cleaned up now, but the cleanup was not aleady done
|
||||
}
|
||||
|
||||
func SetStatus(gitea common.Gitea, org, repo, hash string, status *models.CommitStatus) error {
|
||||
_, err := gitea.SetCommitStatus(org, repo, hash, status)
|
||||
if err != nil {
|
||||
common.LogError(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, error) {
|
||||
dir, err := os.MkdirTemp(os.TempDir(), BotName)
|
||||
common.PanicOnError(err)
|
||||
@@ -684,6 +764,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
|
||||
stagingConfig, err := common.ParseStagingConfig(data)
|
||||
if err != nil {
|
||||
common.LogError("Error parsing config file", common.StagingConfigFile, err)
|
||||
return true, err
|
||||
}
|
||||
|
||||
if stagingConfig.ObsProject == "" {
|
||||
@@ -696,7 +777,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
|
||||
}
|
||||
|
||||
meta, err := ObsClient.GetProjectMeta(stagingConfig.ObsProject)
|
||||
if err != nil {
|
||||
if err != nil || meta == nil {
|
||||
common.LogError("Cannot find reference project meta:", stagingConfig.ObsProject, err)
|
||||
if !IsDryRun {
|
||||
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot fetch reference project meta")
|
||||
@@ -757,23 +838,28 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
|
||||
common.LogDebug(" # head submodules:", len(headSubmodules))
|
||||
common.LogDebug(" # base submodules:", len(baseSubmodules))
|
||||
|
||||
modifiedOrNew := make([]string, 0, 16)
|
||||
modifiedPackages := make([]string, 0, 16)
|
||||
newPackages := make([]string, 0, 16)
|
||||
if !stagingConfig.RebuildAll {
|
||||
for pkg, headOid := range headSubmodules {
|
||||
if baseOid, exists := baseSubmodules[pkg]; !exists || baseOid != headOid {
|
||||
modifiedOrNew = append(modifiedOrNew, pkg)
|
||||
if exists {
|
||||
modifiedPackages = append(modifiedPackages, pkg)
|
||||
} else {
|
||||
newPackages = append(newPackages, pkg)
|
||||
}
|
||||
common.LogDebug(pkg, ":", baseOid, "->", headOid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(modifiedOrNew) == 0 {
|
||||
if len(modifiedPackages) == 0 && len(newPackages) == 0 {
|
||||
rebuild_all := false || stagingConfig.RebuildAll
|
||||
|
||||
reviews, err := gitea.GetPullRequestReviews(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index)
|
||||
common.LogDebug("num reviews:", len(reviews))
|
||||
if err == nil {
|
||||
rebuild_rx := regexp.MustCompile("^@autogits_obs_staging_bot\\s*:\\s*(re)?build\\s*all$")
|
||||
rebuild_rx := regexp.MustCompile("^@autogits_obs_staging_bot\\s*:?\\s*(re)?build\\s*all$")
|
||||
done:
|
||||
for _, r := range reviews {
|
||||
for _, l := range common.SplitLines(r.Body) {
|
||||
@@ -805,7 +891,12 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
|
||||
if !rebuild_all {
|
||||
common.LogInfo("No package changes detected. Ignoring")
|
||||
if !IsDryRun {
|
||||
_, err = gitea.AddReviewComment(pr, common.ReviewStateComment, "No package changes. Not rebuilding project by default")
|
||||
_, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, "No package changes, not rebuilding project by default, accepting change")
|
||||
if err != nil {
|
||||
common.LogError(err)
|
||||
} else {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
@@ -821,6 +912,22 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
|
||||
TargetURL: ObsWebHost + "/project/show/" + stagingProject,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
msg := "Unable to setup stage project " + stagingConfig.ObsProject
|
||||
status.Status = common.CommitStatus_Fail
|
||||
common.LogError(msg)
|
||||
if !IsDryRun {
|
||||
SetStatus(gitea, org, repo, pr.Head.Sha, status)
|
||||
_, err = gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, msg)
|
||||
if err != nil {
|
||||
common.LogError(err)
|
||||
} else {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
msg := "Changed source updated for build"
|
||||
if change == RequestModificationProjectCreated {
|
||||
msg = "Build is started in " + ObsWebHost + "/project/show/" +
|
||||
@@ -829,13 +936,13 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
|
||||
if len(stagingConfig.QA) > 0 {
|
||||
msg = msg + "\nAdditional QA builds: \n"
|
||||
}
|
||||
gitea.SetCommitStatus(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Head.Sha, status)
|
||||
|
||||
SetStatus(gitea, org, repo, pr.Head.Sha, status)
|
||||
for _, setup := range stagingConfig.QA {
|
||||
CreateQASubProject(stagingConfig, git, gitea, pr,
|
||||
stagingProject,
|
||||
setup.Origin,
|
||||
setup.Name)
|
||||
setup.Name,
|
||||
setup.BuildDisableRepos)
|
||||
msg = msg + ObsWebHost + "/project/show/" +
|
||||
stagingProject + ":" + setup.Name + "\n"
|
||||
}
|
||||
@@ -844,42 +951,44 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
|
||||
gitea.AddComment(pr, msg)
|
||||
}
|
||||
|
||||
baseResult, err := ObsClient.LastBuildResults(stagingConfig.ObsProject, modifiedOrNew...)
|
||||
baseResult, err := ObsClient.LastBuildResults(stagingConfig.ObsProject, modifiedPackages...)
|
||||
if err != nil {
|
||||
common.LogError("failed fetching ref project status for", stagingConfig.ObsProject, ":", err)
|
||||
}
|
||||
stagingResult, err := ObsClient.BuildStatus(stagingProject)
|
||||
if err != nil {
|
||||
common.LogError("failed fetching ref project status for", stagingProject, ":", err)
|
||||
common.LogError("failed fetching stage project status for", stagingProject, ":", err)
|
||||
}
|
||||
buildStatus := ProcessBuildStatus(stagingResult, baseResult)
|
||||
|
||||
done := false
|
||||
switch buildStatus {
|
||||
case BuildStatusSummarySuccess:
|
||||
status.Status = common.CommitStatus_Success
|
||||
done = true
|
||||
if !IsDryRun {
|
||||
_, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, "Build successful")
|
||||
if err != nil {
|
||||
common.LogError(err)
|
||||
} else {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
case BuildStatusSummaryFailed:
|
||||
status.Status = common.CommitStatus_Fail
|
||||
done = true
|
||||
if !IsDryRun {
|
||||
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Build failed")
|
||||
if err != nil {
|
||||
common.LogError(err)
|
||||
} else {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
common.LogInfo("Build status:", buildStatus)
|
||||
gitea.SetCommitStatus(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Head.Sha, status)
|
||||
|
||||
// waiting for build results -- nothing to do
|
||||
if !IsDryRun {
|
||||
if err = SetStatus(gitea, org, repo, pr.Head.Sha, status); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return done, nil
|
||||
|
||||
} else if err == NonActionableReviewError || err == NoReviewsFoundError {
|
||||
return true, nil
|
||||
@@ -938,7 +1047,6 @@ func PollWorkNotifications(giteaUrl string) {
|
||||
|
||||
var ListPullNotificationsOnly bool
|
||||
var GiteaUrl string
|
||||
var GiteaUseSshClone bool
|
||||
var ObsWebHost string
|
||||
var IsDryRun bool
|
||||
var ProcessPROnly string
|
||||
@@ -962,7 +1070,6 @@ func main() {
|
||||
ProcessPROnly := flag.String("pr", "", "Process only specific PR and ignore the rest. Use for debugging")
|
||||
buildRoot := flag.String("build-root", "", "Default build location for staging projects. Default is bot's home project")
|
||||
flag.StringVar(&GiteaUrl, "gitea-url", "https://src.opensuse.org", "Gitea instance")
|
||||
flag.BoolVar(&GiteaUseSshClone, "use-ssh-clone", false, "enforce cloning via ssh")
|
||||
obsApiHost := flag.String("obs", "https://api.opensuse.org", "API for OBS instance")
|
||||
flag.StringVar(&ObsWebHost, "obs-web", "", "Web OBS instance, if not derived from the obs config")
|
||||
flag.BoolVar(&IsDryRun, "dry", false, "Dry-run, don't actually create any build projects or review changes")
|
||||
@@ -979,6 +1086,7 @@ func main() {
|
||||
ObsWebHost = ObsWebHostFromApiHost(*obsApiHost)
|
||||
}
|
||||
|
||||
common.LogDebug("OBS Gitea Host:", GiteaUrl)
|
||||
common.LogDebug("OBS Web Host:", ObsWebHost)
|
||||
common.LogDebug("OBS API Host:", *obsApiHost)
|
||||
|
||||
@@ -996,7 +1104,7 @@ func main() {
|
||||
}
|
||||
|
||||
if len(*ProcessPROnly) > 0 {
|
||||
rx := regexp.MustCompile("^(\\w+)/(\\w+)#(\\d+)$")
|
||||
rx := regexp.MustCompile("^([^/#]+)/([^/#]+)#([0-9]+)$")
|
||||
m := rx.FindStringSubmatch(*ProcessPROnly)
|
||||
if m == nil {
|
||||
common.LogError("Cannot find any PR matches in", *ProcessPROnly)
|
||||
|
||||
1
obs-status-service/.gitignore
vendored
1
obs-status-service/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
obs-status-service
|
||||
*.svg
|
||||
|
||||
@@ -1,25 +1,60 @@
|
||||
OBS Status Service
|
||||
==================
|
||||
|
||||
Reports build status of OBS service as an easily to produce SVG
|
||||
Reports build status of OBS service as an easily to produce SVG. Repository
|
||||
results (build results) are cached for 10 seconds and repository listing
|
||||
for OBS instance are cached for 5 minutes -- new repositories take up to
|
||||
5 minutes to be visible.
|
||||
|
||||
Requests for individual build results:
|
||||
/obs:project/package/repo/arch
|
||||
|
||||
/status/obs:project/package/repo/arch
|
||||
|
||||
where `repo` and `arch` are optional parameters.
|
||||
|
||||
Requests for project results
|
||||
/obs:project
|
||||
|
||||
/status/obs:project
|
||||
|
||||
Get requests for / will also return 404 statu normally. If the Backend redis
|
||||
server is not available, it will return 500
|
||||
|
||||
|
||||
By default, SVG output is generated, suitable for inclusion. But JSON and XML
|
||||
output is possible by setting `Accept:` request header
|
||||
|
||||
| Accept Request Header | Output format
|
||||
|------------------------|---------------------
|
||||
| | SVG image
|
||||
| application/json | JSON data
|
||||
| application/obs+xml | XML output
|
||||
|
||||
|
||||
Areas of Responsibility
|
||||
-----------------------
|
||||
|
||||
* Monitors RabbitMQ interface for notification of OBS package and project status
|
||||
* Produces SVG output based on GET request
|
||||
* Cache results (sqlite) and periodically update results from OBS (in case of messages are missing)
|
||||
* Fetch and cache internal data from OBS and present it in usable format:
|
||||
+ Generate SVG output for specific OBS project or package
|
||||
+ Generate JSON/XML output for automated processing
|
||||
* Low-overhead
|
||||
|
||||
|
||||
Target Usage
|
||||
------------
|
||||
|
||||
* README.md of package git or project git
|
||||
* inside README.md of package git or project git
|
||||
* comment section of a Gitea PR
|
||||
* automated build result processing
|
||||
|
||||
Running
|
||||
-------
|
||||
|
||||
Default parameters can be changed by env variables
|
||||
|
||||
| Environment variable | Default | Description
|
||||
|---------------------------------|-----------------------------|------------
|
||||
| `OBS_STATUS_SERVICE_OBS_URL` | https://build.opensuse.org | Location for creating build logs and monitor page build results
|
||||
| `OBS_STATUS_SERVICE_LISTEN` | [::1]:8080 | Listening address and port
|
||||
| `OBS_STATUS_SERVICE_CERT` | /run/obs-status-service.pem | Location of certificate file for service
|
||||
| `OBS_STATUS_SERVICE_KEY` | /run/obs-status-service.pem | Location of key file for service
|
||||
| `REDIS` | | OBS's Redis instance URL
|
||||
|
||||
BIN
obs-status-service/factory.results.json.bz2
LFS
Normal file
BIN
obs-status-service/factory.results.json.bz2
LFS
Normal file
Binary file not shown.
BIN
obs-status-service/gcc15.results.json.bz2
LFS
Normal file
BIN
obs-status-service/gcc15.results.json.bz2
LFS
Normal file
Binary file not shown.
@@ -19,11 +19,20 @@ package main
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
@@ -33,52 +42,131 @@ const (
|
||||
)
|
||||
|
||||
var obs *common.ObsClient
|
||||
var debug bool
|
||||
|
||||
func LogDebug(v ...any) {
|
||||
if debug {
|
||||
log.Println(v...)
|
||||
}
|
||||
type RepoBuildCounters struct {
|
||||
Repository, Arch string
|
||||
Status string
|
||||
BuildStatusCounter map[string]int
|
||||
}
|
||||
|
||||
func ProjectStatusSummarySvg(project string) []byte {
|
||||
res := GetCurrentStatus(project)
|
||||
if res == nil {
|
||||
func ProjectStatusSummarySvg(res []*common.BuildResult) []byte {
|
||||
if len(res) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkgs := res.GetPackageList()
|
||||
list := common.BuildResultList{
|
||||
Result: res,
|
||||
}
|
||||
package_names := list.GetPackageList()
|
||||
maxLen := 0
|
||||
for _, p := range pkgs {
|
||||
for _, p := range package_names {
|
||||
maxLen = max(maxLen, len(p))
|
||||
}
|
||||
|
||||
width := float32(len(res.Result))*1.5 + float32(maxLen)*0.8
|
||||
height := 1.5*float32(maxLen) + 30
|
||||
// width := float32(len(list.Result))*1.5 + float32(maxLen)*0.8
|
||||
// height := 1.5*float32(maxLen) + 30
|
||||
ret := NewSvg(SvgType_Project)
|
||||
|
||||
ret := bytes.Buffer{}
|
||||
ret.WriteString(`<svg version="2.0" width="`)
|
||||
ret.WriteString(fmt.Sprint(width))
|
||||
ret.WriteString(`em" height="`)
|
||||
ret.WriteString(fmt.Sprint(height))
|
||||
ret.WriteString(`em" xmlns="http://www.w3.org/2000/svg">`)
|
||||
ret.WriteString(`<defs>
|
||||
<g id="f"> <!-- failed -->
|
||||
<rect width="1em" height="1em" fill="#800" />
|
||||
</g>
|
||||
<g id="s"> <!--succeeded-->
|
||||
<rect width="1em" height="1em" fill="#080" />
|
||||
</g>
|
||||
<g id="buidling"> <!--building-->
|
||||
<rect width="1em" height="1em" fill="#880" />
|
||||
</g>
|
||||
</defs>`)
|
||||
ret.WriteString(`<use href="#f" x="1em" y="2em"/>`)
|
||||
ret.WriteString(`</svg>`)
|
||||
return ret.Bytes()
|
||||
status := make([]RepoBuildCounters, len(res))
|
||||
|
||||
for i, repo := range res {
|
||||
status[i].Arch = repo.Arch
|
||||
status[i].Repository = repo.Repository
|
||||
status[i].Status = repo.Code
|
||||
status[i].BuildStatusCounter = make(map[string]int)
|
||||
|
||||
for _, pkg := range repo.Status {
|
||||
status[i].BuildStatusCounter[pkg.Code]++
|
||||
}
|
||||
}
|
||||
slices.SortFunc(status, func(a, b RepoBuildCounters) int {
|
||||
if r := strings.Compare(a.Repository, b.Repository); r != 0 {
|
||||
return r
|
||||
}
|
||||
return strings.Compare(a.Arch, b.Arch)
|
||||
})
|
||||
repoName := ""
|
||||
ret.ypos = 3.0
|
||||
for _, repo := range status {
|
||||
if repo.Repository != repoName {
|
||||
repoName = repo.Repository
|
||||
ret.WriteTitle(repoName)
|
||||
}
|
||||
|
||||
ret.WriteSubtitle(repo.Arch)
|
||||
statuses := slices.Sorted(maps.Keys(repo.BuildStatusCounter))
|
||||
for _, status := range statuses {
|
||||
ret.WriteProjectStatus(res[0].Project, repo.Repository, repo.Arch, status, repo.BuildStatusCounter[status])
|
||||
}
|
||||
}
|
||||
|
||||
return ret.GenerateSvg()
|
||||
}
|
||||
|
||||
func PackageStatusSummarySvg(status common.PackageBuildStatus) []byte {
|
||||
func LinkToBuildlog(R *common.BuildResult, S *common.PackageBuildStatus) string {
|
||||
if R != nil && S != nil {
|
||||
switch S.Code {
|
||||
case "succeeded", "failed", "building":
|
||||
return "/buildlog/" + url.PathEscape(R.Project) + "/" + url.PathEscape(S.Package) + "/" + url.PathEscape(R.Repository) + "/" + url.PathEscape(R.Arch)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func DeleteExceptPkg(pkg string) func(*common.PackageBuildStatus) bool {
|
||||
return func(item *common.PackageBuildStatus) bool {
|
||||
multibuild_prefix := pkg + ":"
|
||||
return item.Package != pkg && !strings.HasPrefix(item.Package, multibuild_prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func PackageStatusSummarySvg(pkg string, res []*common.BuildResult) []byte {
|
||||
// per repo, per arch status bins
|
||||
repo_names := []string{}
|
||||
package_names := []string{}
|
||||
multibuild_prefix := pkg + ":"
|
||||
for _, r := range res {
|
||||
if pos, found := slices.BinarySearchFunc(repo_names, r.Repository, strings.Compare); !found {
|
||||
repo_names = slices.Insert(repo_names, pos, r.Repository)
|
||||
}
|
||||
|
||||
for _, p := range r.Status {
|
||||
if p.Package == pkg || strings.HasPrefix(p.Package, multibuild_prefix) {
|
||||
if pos, found := slices.BinarySearchFunc(package_names, p.Package, strings.Compare); !found {
|
||||
package_names = slices.Insert(package_names, pos, p.Package)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ret := NewSvg(SvgType_Package)
|
||||
for _, pkg = range package_names {
|
||||
// if len(package_names) > 1 {
|
||||
ret.WriteTitle(pkg)
|
||||
// }
|
||||
|
||||
for _, name := range repo_names {
|
||||
ret.WriteSubtitle(name)
|
||||
// print all repo arches here and build results
|
||||
for _, r := range res {
|
||||
if r.Repository != name {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, s := range r.Status {
|
||||
if s.Package == pkg {
|
||||
link := LinkToBuildlog(r, s)
|
||||
ret.WritePackageStatus(link, r.Arch, s.Code, s.Details)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret.GenerateSvg()
|
||||
}
|
||||
|
||||
func BuildStatusSvg(repo *common.BuildResult, status *common.PackageBuildStatus) []byte {
|
||||
buildStatus, ok := common.ObsBuildStatusDetails[status.Code]
|
||||
if !ok {
|
||||
buildStatus = common.ObsBuildStatusDetails["error"]
|
||||
@@ -95,73 +183,256 @@ func PackageStatusSummarySvg(status common.PackageBuildStatus) []byte {
|
||||
}
|
||||
}
|
||||
|
||||
log.Println(status, " -> ", buildStatus)
|
||||
buildlog := LinkToBuildlog(repo, status)
|
||||
startTag := ""
|
||||
endTag := ""
|
||||
|
||||
return []byte(`<svg version="2.0" width="8em" height="1.5em" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="` + fillColor + `"/>
|
||||
<text x="4em" y="1.1em" text-anchor="middle" fill="` + textColor + `">` + buildStatus.Code + `</text>
|
||||
</svg>`)
|
||||
}
|
||||
|
||||
func main() {
|
||||
cert := flag.String("cert-file", "", "TLS certificates file")
|
||||
key := flag.String("key-file", "", "Private key for the TLS certificate")
|
||||
listen := flag.String("listen", "[::1]:8080", "Listening string")
|
||||
disableTls := flag.Bool("no-tls", false, "Disable TLS")
|
||||
obsHost := flag.String("obs-host", "api.opensuse.org", "OBS API endpoint for package status information")
|
||||
flag.BoolVar(&debug, "debug", false, "Enable debug logging")
|
||||
flag.Parse()
|
||||
|
||||
common.PanicOnError(common.RequireObsSecretToken())
|
||||
|
||||
var err error
|
||||
if obs, err = common.NewObsClient(*obsHost); err != nil {
|
||||
log.Fatal(err)
|
||||
if len(buildlog) > 0 {
|
||||
startTag = "<a href=\"" + buildlog + "\">"
|
||||
endTag = "</a>"
|
||||
}
|
||||
|
||||
http.HandleFunc("GET /{Project}", func(res http.ResponseWriter, req *http.Request) {
|
||||
res.WriteHeader(http.StatusBadRequest)
|
||||
})
|
||||
http.HandleFunc("GET /{Project}/{Package}", func(res http.ResponseWriter, req *http.Request) {
|
||||
/*
|
||||
obsPrj := req.PathValue("Project")
|
||||
obsPkg := req.PathValue("Package")
|
||||
return []byte(`<svg version="2.0" width="8em" height="1.5em" xmlns="http://www.w3.org/2000/svg">` +
|
||||
`<rect width="100%" height="100%" fill="` + fillColor + `"/>` + startTag +
|
||||
`<text x="4em" y="1.1em" text-anchor="middle" fill="` + textColor + `">` + html.EscapeString(buildStatus.Code) + `</text>` + endTag + `</svg>`)
|
||||
}
|
||||
|
||||
status, _ := PackageBuildStatus(obsPrj, obsPkg)
|
||||
svg := PackageStatusSummarySvg(status)
|
||||
*/
|
||||
func WriteJson(data any, res http.ResponseWriter) {
|
||||
if jsonArray, err := json.MarshalIndent(data, "", " "); err != nil {
|
||||
res.WriteHeader(500)
|
||||
} else {
|
||||
res.Header().Add("size", fmt.Sprint(len(jsonArray)))
|
||||
res.Write(jsonArray)
|
||||
}
|
||||
}
|
||||
|
||||
res.Header().Add("content-type", "image/svg+xml")
|
||||
//res.Header().Add("size", fmt.Sprint(len(svg)))
|
||||
//res.Write(svg)
|
||||
func WriteXml(data any, res http.ResponseWriter) {
|
||||
if xmlData, err := xml.MarshalIndent(data, "", " "); err != nil {
|
||||
res.WriteHeader(500)
|
||||
} else {
|
||||
res.Header().Add("size", fmt.Sprint(len(xmlData)))
|
||||
res.Write([]byte("<resultlist>"))
|
||||
res.Write(xmlData)
|
||||
res.Write([]byte("</resultlist>"))
|
||||
}
|
||||
}
|
||||
|
||||
var ObsUrl *string
|
||||
|
||||
func main() {
|
||||
obsUrlDef := os.Getenv("OBS_STATUS_SERVICE_OBS_URL")
|
||||
if len(obsUrlDef) == 0 {
|
||||
obsUrlDef = "https://build.opensuse.org"
|
||||
}
|
||||
listenDef := os.Getenv("OBS_STATUS_SERVICE_LISTEN")
|
||||
if len(listenDef) == 0 {
|
||||
listenDef = "[::1]:8080"
|
||||
}
|
||||
certDef := os.Getenv("OBS_STATUS_SERVICE_CERT")
|
||||
if len(certDef) == 0 {
|
||||
certDef = "/run/obs-status-service.pem"
|
||||
}
|
||||
keyDef := os.Getenv("OBS_STATUS_SERVICE_KEY")
|
||||
if len(keyDef) == 0 {
|
||||
keyDef = certDef
|
||||
}
|
||||
|
||||
cert := flag.String("cert-file", certDef, "TLS certificates file")
|
||||
key := flag.String("key-file", keyDef, "Private key for the TLS certificate")
|
||||
listen := flag.String("listen", listenDef, "Listening string")
|
||||
disableTls := flag.Bool("no-tls", false, "Disable TLS")
|
||||
ObsUrl = flag.String("obs-url", obsUrlDef, "OBS API endpoint for package buildlog information")
|
||||
debug := flag.Bool("debug", false, "Enable debug logging")
|
||||
flag.Parse()
|
||||
|
||||
if *debug {
|
||||
common.SetLoggingLevel(common.LogLevelDebug)
|
||||
}
|
||||
|
||||
if redisUrl := os.Getenv("REDIS"); len(redisUrl) > 0 {
|
||||
RedisConnect(redisUrl)
|
||||
} else {
|
||||
common.LogError("REDIS needs to contains URL of the OBS Redis instance with login information")
|
||||
return
|
||||
}
|
||||
|
||||
var rescanRepoError error
|
||||
go func() {
|
||||
for {
|
||||
if rescanRepoError = RescanRepositories(); rescanRepoError != nil {
|
||||
common.LogError("Failed to rescan repositories.", rescanRepoError)
|
||||
}
|
||||
time.Sleep(time.Minute * 5)
|
||||
}
|
||||
}()
|
||||
|
||||
http.HandleFunc("GET /", func(res http.ResponseWriter, req *http.Request) {
|
||||
if rescanRepoError != nil {
|
||||
res.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
res.WriteHeader(404)
|
||||
res.Write([]byte("404 page not found\n"))
|
||||
})
|
||||
http.HandleFunc("GET /{Project}/{Package}/{Repository}/{Arch}", func(res http.ResponseWriter, req *http.Request) {
|
||||
http.HandleFunc("GET /status/{Project}", func(res http.ResponseWriter, req *http.Request) {
|
||||
mime := ParseMimeHeader(req)
|
||||
obsPrj := req.PathValue("Project")
|
||||
common.LogInfo(" GET /status/"+obsPrj, "["+mime.MimeType()+"]")
|
||||
|
||||
status := FindAndUpdateProjectResults(obsPrj)
|
||||
if len(status) == 0 {
|
||||
res.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
res.Header().Add("content-type", mime.MimeHeader)
|
||||
if mime.IsSvg() {
|
||||
svg := ProjectStatusSummarySvg(status)
|
||||
res.Header().Add("size", fmt.Sprint(len(svg)))
|
||||
res.Write(svg)
|
||||
} else if mime.IsJson() {
|
||||
WriteJson(status, res)
|
||||
} else if mime.IsXml() {
|
||||
WriteXml(status, res)
|
||||
}
|
||||
})
|
||||
http.HandleFunc("GET /status/{Project}/{Package}", func(res http.ResponseWriter, req *http.Request) {
|
||||
mime := ParseMimeHeader(req)
|
||||
obsPrj := req.PathValue("Project")
|
||||
obsPkg := req.PathValue("Package")
|
||||
common.LogInfo(" GET /status/"+obsPrj+"/"+obsPkg, "["+mime.MimeType()+"]")
|
||||
|
||||
status := slices.Clone(FindAndUpdateProjectResults(obsPrj))
|
||||
for i, s := range status {
|
||||
f := *s
|
||||
f.Status = slices.DeleteFunc(slices.Clone(s.Status), DeleteExceptPkg(obsPkg))
|
||||
status[i] = &f
|
||||
}
|
||||
if len(status) == 0 {
|
||||
res.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
|
||||
res.Header().Add("content-type", mime.MimeHeader)
|
||||
if mime.IsSvg() {
|
||||
svg := PackageStatusSummarySvg(obsPkg, status)
|
||||
|
||||
res.Header().Add("size", fmt.Sprint(len(svg)))
|
||||
res.Write(svg)
|
||||
} else if mime.IsJson() {
|
||||
WriteJson(status, res)
|
||||
} else if mime.IsXml() {
|
||||
WriteXml(status, res)
|
||||
}
|
||||
|
||||
})
|
||||
http.HandleFunc("GET /status/{Project}/{Package}/{Repository}", func(res http.ResponseWriter, req *http.Request) {
|
||||
mime := ParseMimeHeader(req)
|
||||
obsPrj := req.PathValue("Project")
|
||||
obsPkg := req.PathValue("Package")
|
||||
repo := req.PathValue("Repository")
|
||||
common.LogInfo(" GET /status/"+obsPrj+"/"+obsPkg, "["+mime.MimeType()+"]")
|
||||
|
||||
status := slices.Clone(FindAndUpdateRepoResults(obsPrj, repo))
|
||||
for i, s := range status {
|
||||
f := *s
|
||||
f.Status = slices.DeleteFunc(slices.Clone(s.Status), DeleteExceptPkg(obsPkg))
|
||||
status[i] = &f
|
||||
}
|
||||
if len(status) == 0 {
|
||||
res.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
|
||||
if mime.IsSvg() {
|
||||
svg := PackageStatusSummarySvg(obsPkg, status)
|
||||
res.Header().Add("content-type", mime.MimeHeader)
|
||||
res.Header().Add("size", fmt.Sprint(len(svg)))
|
||||
res.Write(svg)
|
||||
} else if mime.IsJson() {
|
||||
WriteJson(status, res)
|
||||
} else if mime.IsXml() {
|
||||
WriteXml(status, res)
|
||||
}
|
||||
})
|
||||
http.HandleFunc("GET /status/{Project}/{Package}/{Repository}/{Arch}", func(res http.ResponseWriter, req *http.Request) {
|
||||
mime := ParseMimeHeader(req)
|
||||
prj := req.PathValue("Project")
|
||||
pkg := req.PathValue("Package")
|
||||
repo := req.PathValue("Repository")
|
||||
arch := req.PathValue("Arch")
|
||||
common.LogInfo(" GET /status/"+prj+"/"+pkg+"/"+repo+"/"+arch, "["+mime.MimeType()+"]")
|
||||
|
||||
res.Header().Add("content-type", mime.MimeHeader)
|
||||
for _, r := range FindAndUpdateRepoResults(prj, repo) {
|
||||
if r.Arch == arch {
|
||||
if idx, found := slices.BinarySearchFunc(r.Status, &common.PackageBuildStatus{Package: pkg}, common.PackageBuildStatusComp); found {
|
||||
status := r.Status[idx]
|
||||
if mime.IsSvg() {
|
||||
res.Write(BuildStatusSvg(r, status))
|
||||
} else if mime.IsJson() {
|
||||
WriteJson(status, res)
|
||||
} else if mime.IsXml() {
|
||||
WriteXml(status, res)
|
||||
}
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if mime.IsSvg() {
|
||||
res.Write(BuildStatusSvg(nil, &common.PackageBuildStatus{Package: pkg, Code: "unknown"}))
|
||||
}
|
||||
})
|
||||
http.HandleFunc("GET /search", func(res http.ResponseWriter, req *http.Request) {
|
||||
common.LogInfo("GET /search?" + req.URL.RawQuery)
|
||||
queries := req.URL.Query()
|
||||
if !queries.Has("q") {
|
||||
res.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
|
||||
names := queries["q"]
|
||||
if len(names) != 1 {
|
||||
res.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
|
||||
packages := FindPackages(names[0])
|
||||
data, err := json.MarshalIndent(packages, "", " ")
|
||||
if err != nil {
|
||||
res.WriteHeader(500)
|
||||
common.LogError("Error in marshalling data.", err)
|
||||
return
|
||||
}
|
||||
|
||||
res.Write(data)
|
||||
res.Header().Add("content-type", "application/json")
|
||||
res.WriteHeader(200)
|
||||
})
|
||||
|
||||
http.HandleFunc("GET /buildlog/{Project}/{Package}/{Repository}/{Arch}", func(res http.ResponseWriter, req *http.Request) {
|
||||
prj := req.PathValue("Project")
|
||||
pkg := req.PathValue("Package")
|
||||
repo := req.PathValue("Repository")
|
||||
arch := req.PathValue("Arch")
|
||||
|
||||
res.Header().Add("content-type", "image/svg+xml")
|
||||
res.Header().Add("location", *ObsUrl+"/package/live_build_log/"+url.PathEscape(prj)+"/"+url.PathEscape(pkg)+"/"+url.PathEscape(repo)+"/"+url.PathEscape(arch))
|
||||
res.WriteHeader(307)
|
||||
return
|
||||
|
||||
prjStatus := GetCurrentStatus(prj)
|
||||
if prjStatus == nil {
|
||||
// status := GetDetailedBuildStatus(prj, pkg, repo, arch)
|
||||
data, err := obs.BuildLog(prj, pkg, repo, arch)
|
||||
if err != nil {
|
||||
res.WriteHeader(http.StatusInternalServerError)
|
||||
common.LogError("Failed to fetch build log for:", prj, pkg, repo, arch, err)
|
||||
return
|
||||
}
|
||||
defer data.Close()
|
||||
|
||||
for _, r := range prjStatus.Result {
|
||||
if r.Arch == arch && r.Repository == repo {
|
||||
for _, status := range r.Status {
|
||||
if status.Package == pkg {
|
||||
res.Write(PackageStatusSummarySvg(status))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
io.Copy(res, data)
|
||||
})
|
||||
|
||||
go ProcessUpdates()
|
||||
|
||||
if *disableTls {
|
||||
log.Fatal(http.ListenAndServe(*listen, nil))
|
||||
} else {
|
||||
|
||||
120
obs-status-service/main_test.go
Normal file
120
obs-status-service/main_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/bzip2"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
func TestStatusSvg(t *testing.T) {
|
||||
ObsUrl = &[]string{"http://nothing.is.here"}[0]
|
||||
os.WriteFile("teststatus.svg", BuildStatusSvg(nil, &common.PackageBuildStatus{
|
||||
Package: "foo",
|
||||
Code: "succeeded",
|
||||
Details: "more success here",
|
||||
}), 0o777)
|
||||
|
||||
data := []*common.BuildResult{
|
||||
{
|
||||
Project: "project:foo",
|
||||
Repository: "repo1",
|
||||
Arch: "x86_64",
|
||||
Status: []*common.PackageBuildStatus{
|
||||
{
|
||||
Package: "pkg1",
|
||||
Code: "succeeded",
|
||||
},
|
||||
{
|
||||
Package: "pkg2",
|
||||
Code: "failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Project: "project:foo",
|
||||
Repository: "repo1",
|
||||
Arch: "s390x",
|
||||
Status: []*common.PackageBuildStatus{
|
||||
{
|
||||
Package: "pkg1",
|
||||
Code: "succeeded",
|
||||
},
|
||||
{
|
||||
Package: "pkg2",
|
||||
Code: "unresolveable",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Project: "project:foo",
|
||||
Repository: "repo1",
|
||||
Arch: "i586",
|
||||
Status: []*common.PackageBuildStatus{
|
||||
{
|
||||
Package: "pkg1",
|
||||
Code: "succeeded",
|
||||
},
|
||||
{
|
||||
Package: "pkg2",
|
||||
Code: "blocked",
|
||||
Details: "foo bar is why",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Project: "project:foo",
|
||||
Repository: "TW",
|
||||
Arch: "s390",
|
||||
Status: []*common.PackageBuildStatus{
|
||||
{
|
||||
Package: "pkg1",
|
||||
Code: "excluded",
|
||||
},
|
||||
{
|
||||
Package: "pkg2",
|
||||
Code: "failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
os.WriteFile("testpackage.svg", PackageStatusSummarySvg("pkg2", data), 0o777)
|
||||
os.WriteFile("testproject.svg", ProjectStatusSummarySvg(data), 0o777)
|
||||
}
|
||||
|
||||
func TestFactoryResults(t *testing.T) {
|
||||
data, err := os.Open("factory.results.json.bz2")
|
||||
if err != nil {
|
||||
t.Fatal("Openning factory.results.json.bz2 failed:", err)
|
||||
}
|
||||
UncompressedData, err := io.ReadAll(bzip2.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal("Reading factory.results.json.bz2 failed:", err)
|
||||
}
|
||||
|
||||
var results []*common.BuildResult
|
||||
if err := json.Unmarshal(UncompressedData, &results); err != nil {
|
||||
t.Fatal("Failed parsing test data", err)
|
||||
}
|
||||
|
||||
// add tests here
|
||||
tests := []struct {
|
||||
name string
|
||||
}{
|
||||
// add test data here
|
||||
{
|
||||
name: "First test",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// and test code here
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
62
obs-status-service/mimeheader.go
Normal file
62
obs-status-service/mimeheader.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MimeHeader struct {
|
||||
MimeHeader string
|
||||
}
|
||||
|
||||
const (
|
||||
JsonMime = "application/json"
|
||||
XmlMime = "application/obs+xml"
|
||||
SvgMime = "image/svg+xml"
|
||||
)
|
||||
|
||||
var AcceptedStatusMimes []string = []string{
|
||||
SvgMime,
|
||||
JsonMime,
|
||||
XmlMime,
|
||||
}
|
||||
|
||||
func ParseMimeHeader(req *http.Request) *MimeHeader {
|
||||
proposedMimes := req.Header.Values("Accept")
|
||||
mime := MimeHeader{MimeHeader: SvgMime}
|
||||
if len(proposedMimes) == 0 {
|
||||
return &mime
|
||||
}
|
||||
|
||||
for _, m := range proposedMimes {
|
||||
for _, am := range AcceptedStatusMimes {
|
||||
if strings.Contains(m, am) {
|
||||
mime.MimeHeader = am
|
||||
return &mime
|
||||
}
|
||||
}
|
||||
}
|
||||
return &mime
|
||||
}
|
||||
|
||||
func (m *MimeHeader) IsJson() bool {
|
||||
return m.MimeHeader == JsonMime
|
||||
}
|
||||
|
||||
func (m *MimeHeader) IsXml() bool {
|
||||
return m.MimeHeader == XmlMime
|
||||
}
|
||||
|
||||
func (m *MimeHeader) IsSvg() bool {
|
||||
return m.MimeHeader == SvgMime
|
||||
}
|
||||
|
||||
func (m *MimeHeader) MimeType() string {
|
||||
if m.IsJson() {
|
||||
return JsonMime
|
||||
} else if m.IsXml() {
|
||||
return XmlMime
|
||||
}
|
||||
|
||||
return SvgMime // default
|
||||
}
|
||||
266
obs-status-service/redis.go
Normal file
266
obs-status-service/redis.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
var RepoStatus []*common.BuildResult = []*common.BuildResult{}
|
||||
var RepoStatusLock *sync.RWMutex = &sync.RWMutex{}
|
||||
|
||||
var redisClient *redis.Client
|
||||
|
||||
func RedisConnect(RedisUrl string) {
|
||||
opts, err := redis.ParseURL(RedisUrl)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
redisClient = redis.NewClient(opts)
|
||||
}
|
||||
|
||||
func UpdateResults(r *common.BuildResult) {
|
||||
RepoStatusLock.Lock()
|
||||
defer RepoStatusLock.Unlock()
|
||||
|
||||
updateResultsWithoutLocking(r)
|
||||
}
|
||||
|
||||
func updateResultsWithoutLocking(r *common.BuildResult) {
|
||||
key := "result." + r.Project + "/" + r.Repository + "/" + r.Arch
|
||||
data, err := redisClient.HGetAll(context.Background(), key).Result()
|
||||
if err != nil {
|
||||
common.LogError("Failed fetching build results for", key, err)
|
||||
}
|
||||
|
||||
reset_time := time.Date(1000, 1, 1, 1, 1, 1, 1, time.Local)
|
||||
for _, pkg := range r.Status {
|
||||
pkg.LastUpdate = reset_time
|
||||
}
|
||||
r.LastUpdate = time.Now()
|
||||
for pkg, result := range data {
|
||||
if strings.HasPrefix(result, "scheduled") {
|
||||
// TODO: lookup where's building
|
||||
result = "building"
|
||||
}
|
||||
|
||||
var idx int
|
||||
var found bool
|
||||
var code string
|
||||
var details string
|
||||
|
||||
if pos := strings.IndexByte(result, ':'); pos > -1 && pos < len(result) {
|
||||
code = result[0:pos]
|
||||
details = result[pos+1:]
|
||||
} else {
|
||||
code = result
|
||||
details = ""
|
||||
}
|
||||
|
||||
if idx, found = slices.BinarySearchFunc(r.Status, &common.PackageBuildStatus{Package: pkg}, common.PackageBuildStatusComp); found {
|
||||
res := r.Status[idx]
|
||||
res.LastUpdate = r.LastUpdate
|
||||
res.Code = code
|
||||
res.Details = details
|
||||
} else {
|
||||
r.Status = slices.Insert(r.Status, idx, &common.PackageBuildStatus{
|
||||
Package: pkg,
|
||||
Code: code,
|
||||
Details: details,
|
||||
LastUpdate: r.LastUpdate,
|
||||
})
|
||||
}
|
||||
}
|
||||
for idx := 0; idx < len(r.Status); {
|
||||
if r.Status[idx].LastUpdate == reset_time {
|
||||
r.Status = slices.Delete(r.Status, idx, idx+1)
|
||||
} else {
|
||||
idx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FindProjectResults(project string) []*common.BuildResult {
|
||||
RepoStatusLock.RLock()
|
||||
defer RepoStatusLock.RUnlock()
|
||||
|
||||
return FindProjectResultsNoLock(project)
|
||||
}
|
||||
|
||||
func FindProjectResultsNoLock(project string) []*common.BuildResult {
|
||||
ret := make([]*common.BuildResult, 0, 8)
|
||||
idx, _ := slices.BinarySearchFunc(RepoStatus, &common.BuildResult{Project: project}, common.BuildResultComp)
|
||||
for idx < len(RepoStatus) && RepoStatus[idx].Project == project {
|
||||
ret = append(ret, RepoStatus[idx])
|
||||
idx++
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func FindRepoResults(project, repo string) []*common.BuildResult {
|
||||
RepoStatusLock.RLock()
|
||||
defer RepoStatusLock.RUnlock()
|
||||
|
||||
return FindRepoResultsNoLock(project, repo)
|
||||
}
|
||||
|
||||
func FindRepoResultsNoLock(project, repo string) []*common.BuildResult {
|
||||
ret := make([]*common.BuildResult, 0, 8)
|
||||
idx, _ := slices.BinarySearchFunc(RepoStatus, &common.BuildResult{Project: project, Repository: repo}, common.BuildResultComp)
|
||||
for idx < len(RepoStatus) && RepoStatus[idx].Project == project && RepoStatus[idx].Repository == repo {
|
||||
ret = append(ret, RepoStatus[idx])
|
||||
idx++
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func FindPackages(pkg string) []string {
|
||||
RepoStatusLock.RLock()
|
||||
defer RepoStatusLock.RUnlock()
|
||||
|
||||
return FindPackagesNoLock(pkg)
|
||||
}
|
||||
|
||||
func FindPackagesNoLock(pkg string) []string {
|
||||
data := make([]string, 0, 100)
|
||||
for _, repo := range RepoStatus {
|
||||
for _, status := range repo.Status {
|
||||
if pkg == status.Package {
|
||||
entry := repo.Project + "/" + pkg
|
||||
if idx, found := slices.BinarySearch(data, entry); !found {
|
||||
data = slices.Insert(data, idx, entry)
|
||||
if len(data) >= 100 {
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func FindAndUpdateProjectResults(project string) []*common.BuildResult {
|
||||
res := FindProjectResults(project)
|
||||
wg := &sync.WaitGroup{}
|
||||
now := time.Now()
|
||||
for _, r := range res {
|
||||
if now.Sub(r.LastUpdate).Abs() < time.Second*10 {
|
||||
// 1 update per 10 second for now
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
UpdateResults(r)
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return res
|
||||
}
|
||||
|
||||
func FindAndUpdateRepoResults(project, repo string) []*common.BuildResult {
|
||||
res := FindRepoResults(project, repo)
|
||||
wg := &sync.WaitGroup{}
|
||||
now := time.Now()
|
||||
for _, r := range res {
|
||||
if now.Sub(r.LastUpdate).Abs() < time.Second*10 {
|
||||
// 1 update per 10 second for now
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
UpdateResults(r)
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return res
|
||||
}
|
||||
|
||||
func RescanRepositories() error {
|
||||
ctx := context.Background()
|
||||
var cursor uint64
|
||||
var err error
|
||||
|
||||
common.LogDebug("** starting rescanning ...")
|
||||
RepoStatusLock.Lock()
|
||||
for _, repo := range RepoStatus {
|
||||
repo.Dirty = false
|
||||
}
|
||||
RepoStatusLock.Unlock()
|
||||
var count int
|
||||
|
||||
projectsLooked := make([]string, 0, 10000)
|
||||
|
||||
for {
|
||||
var data []string
|
||||
data, cursor, err = redisClient.ScanType(ctx, cursor, "", 1000, "hash").Result()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
RepoStatusLock.Lock()
|
||||
for _, repo := range data {
|
||||
r := strings.Split(repo, "/")
|
||||
if len(r) != 3 || len(r[0]) < 8 || r[0][0:7] != "result." {
|
||||
continue
|
||||
}
|
||||
d := &common.BuildResult{
|
||||
Project: r[0][7:],
|
||||
Repository: r[1],
|
||||
Arch: r[2],
|
||||
}
|
||||
|
||||
var pos int
|
||||
var found bool
|
||||
if pos, found = slices.BinarySearchFunc(RepoStatus, d, common.BuildResultComp); found {
|
||||
RepoStatus[pos].Dirty = true
|
||||
} else {
|
||||
d.Dirty = true
|
||||
RepoStatus = slices.Insert(RepoStatus, pos, d)
|
||||
count++
|
||||
}
|
||||
|
||||
// fetch all keys, one per non-maintenance/non-home: projects, for package search
|
||||
if idx, found := slices.BinarySearch(projectsLooked, d.Project); !found && !strings.Contains(d.Project, ":Maintenance:") && (len(d.Project) < 5 || d.Project[0:5] != "home:") {
|
||||
projectsLooked = slices.Insert(projectsLooked, idx, d.Project)
|
||||
wg.Add(1)
|
||||
go func(r *common.BuildResult) {
|
||||
updateResultsWithoutLocking(r)
|
||||
wg.Done()
|
||||
}(RepoStatus[pos])
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
RepoStatusLock.Unlock()
|
||||
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
common.LogDebug(" added a total", count, "repos")
|
||||
count = 0
|
||||
|
||||
RepoStatusLock.Lock()
|
||||
for i := 0; i < len(RepoStatus); {
|
||||
if !RepoStatus[i].Dirty {
|
||||
RepoStatus = slices.Delete(RepoStatus, i, i+1)
|
||||
count++
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
RepoStatusLock.Unlock()
|
||||
common.LogDebug(" removed", count, "repos")
|
||||
common.LogDebug(" total repos:", len(RepoStatus))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"src.opensuse.org/autogits/common"
|
||||
)
|
||||
|
||||
var WatchedRepos []string
|
||||
var mutex sync.Mutex
|
||||
|
||||
var StatusUpdateCh chan StatusUpdateMsg = make(chan StatusUpdateMsg)
|
||||
|
||||
var statusMutex sync.RWMutex
|
||||
var CurrentStatus map[string]*common.BuildResultList = make(map[string]*common.BuildResultList)
|
||||
|
||||
type StatusUpdateMsg struct {
|
||||
ObsProject string
|
||||
Result *common.BuildResultList
|
||||
}
|
||||
|
||||
func GetCurrentStatus(project string) *common.BuildResultList {
|
||||
statusMutex.RLock()
|
||||
defer statusMutex.RUnlock()
|
||||
|
||||
if ret, found := CurrentStatus[project]; found {
|
||||
return ret
|
||||
} else {
|
||||
go WatchObsProject(obs, project)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ProcessUpdates() {
|
||||
for {
|
||||
msg := <-StatusUpdateCh
|
||||
|
||||
statusMutex.Lock()
|
||||
CurrentStatus[msg.ObsProject] = msg.Result
|
||||
|
||||
drainedChannel:
|
||||
for {
|
||||
select {
|
||||
case msg = <-StatusUpdateCh:
|
||||
CurrentStatus[msg.ObsProject] = msg.Result
|
||||
default:
|
||||
statusMutex.Unlock()
|
||||
break drainedChannel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WatchObsProject(obs common.ObsStatusFetcherWithState, ObsProject string) {
|
||||
old_state := ""
|
||||
|
||||
mutex.Lock()
|
||||
if pos, found := slices.BinarySearch(WatchedRepos, ObsProject); found {
|
||||
mutex.Unlock()
|
||||
return
|
||||
} else {
|
||||
WatchedRepos = slices.Insert(WatchedRepos, pos, ObsProject)
|
||||
mutex.Unlock()
|
||||
}
|
||||
|
||||
LogDebug("+ watching", ObsProject)
|
||||
opts := common.BuildResultOptions{}
|
||||
for {
|
||||
state, err := obs.BuildStatusWithState(ObsProject, &opts)
|
||||
if err != nil {
|
||||
log.Println(" *** Error fetching build for", ObsProject, err)
|
||||
time.Sleep(time.Minute)
|
||||
} else {
|
||||
opts.OldState = state.State
|
||||
LogDebug(" --> update", ObsProject, " => ", old_state)
|
||||
StatusUpdateCh <- StatusUpdateMsg{ObsProject: ObsProject, Result: state}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"src.opensuse.org/autogits/common"
|
||||
mock_common "src.opensuse.org/autogits/common/mock"
|
||||
)
|
||||
|
||||
func TestWatchObsProject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
res common.BuildResultList
|
||||
}{
|
||||
{
|
||||
name: "two requests",
|
||||
res: common.BuildResultList{
|
||||
State: "success",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctl := gomock.NewController(t)
|
||||
obs := mock_common.NewMockObsStatusFetcherWithState(ctl)
|
||||
|
||||
obs.EXPECT().BuildStatusWithState("test:foo", "").Return(&test.res, nil)
|
||||
|
||||
WatchObsProject(obs, "test:foo")
|
||||
})
|
||||
}
|
||||
}
|
||||
150
obs-status-service/svg.go
Normal file
150
obs-status-service/svg.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SvgWriter struct {
|
||||
ypos float64
|
||||
header []byte
|
||||
out bytes.Buffer
|
||||
}
|
||||
|
||||
const (
|
||||
SvgType_Package = iota
|
||||
SvgType_Project
|
||||
)
|
||||
|
||||
func NewSvg(SvgType int) *SvgWriter {
|
||||
svg := &SvgWriter{}
|
||||
svg.header = []byte(`<svg version="2.0" overflow="auto" width="40ex" height="`)
|
||||
svg.out.WriteString(`em" xmlns="http://www.w3.org/2000/svg">`)
|
||||
switch SvgType {
|
||||
case SvgType_Package:
|
||||
svg.out.WriteString(`<defs>
|
||||
<g id="s">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="green" fill="#efe" rx="5" />
|
||||
<text x="2.5ex" y="1.1em">succeeded</text>
|
||||
</g>
|
||||
<g id="f">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="red" fill="#fee" rx="5" />
|
||||
<text x="5ex" y="1.1em">failed</text>
|
||||
</g>
|
||||
<g id="b">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="grey" fill="#fbf" rx="5" />
|
||||
<text x="3.75ex" y="1.1em">blocked</text>
|
||||
</g>
|
||||
<g id="broken">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="grey" fill="#fff" rx="5" />
|
||||
<text x="4.5ex" y="1.1em" stroke="red" fill="red">broken</text>
|
||||
</g>
|
||||
<g id="build">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="yellow" fill="#664" rx="5" />
|
||||
<text x="3.75ex" y="1.1em" fill="yellow">building</text>
|
||||
</g>
|
||||
<g id="u">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="yellow" fill="#555" rx="5" />
|
||||
<text x="2ex" y="1.1em" fill="orange">unresolvable</text>
|
||||
</g>
|
||||
<g id="scheduled">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="blue" fill="none" rx="5" />
|
||||
<text x="3ex" y="1.1em" stroke="none" fill="blue">scheduled</text>
|
||||
</g>
|
||||
<g id="d">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="grey" fill="none" rx="5" />
|
||||
<text x="4ex" y="1.1em" stroke="none" fill="grey">disabled</text>
|
||||
</g>
|
||||
<g id="e">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="grey" fill="none" rx="5" />
|
||||
<text x="4ex" y="1.1em" stroke="none" fill="#aaf">excluded</text>
|
||||
</g>
|
||||
<g id="un">
|
||||
<rect width="15ex" height="1.5em" stroke-width="1" stroke="grey" fill="none" rx="5" />
|
||||
<text x="4ex" y="1.1em" stroke="none" fill="grey">unknown</text>
|
||||
</g>
|
||||
<rect id="repotitle" width="100%" height="2em" stroke-width="1" stroke="grey" fill="grey" rx="2" />
|
||||
</defs>`)
|
||||
|
||||
case SvgType_Project:
|
||||
svg.out.WriteString(`<defs>
|
||||
</defs>`)
|
||||
}
|
||||
|
||||
return svg
|
||||
}
|
||||
|
||||
func (svg *SvgWriter) WriteTitle(title string) {
|
||||
svg.out.WriteString(`<text stroke="black" fill="black" x="1ex" y="` + fmt.Sprint(svg.ypos-.5) + `em">` + html.EscapeString(title) + "</text>")
|
||||
svg.ypos += 2.5
|
||||
|
||||
}
|
||||
|
||||
func (svg *SvgWriter) WriteSubtitle(subtitle string) {
|
||||
svg.out.WriteString(`<use href="#repotitle" y="` + fmt.Sprint(svg.ypos-2) + `em"/>`)
|
||||
svg.out.WriteString(`<text stroke="black" fill="black" x="3ex" y="` + fmt.Sprint(svg.ypos-.6) + `em">` + html.EscapeString(subtitle) + `</text>`)
|
||||
svg.ypos += 2
|
||||
}
|
||||
|
||||
func (svg *SvgWriter) WritePackageStatus(loglink, arch, status, detail string) {
|
||||
StatusToSVG := func(S string) string {
|
||||
switch S {
|
||||
case "succeeded":
|
||||
return "s"
|
||||
case "failed":
|
||||
return "f"
|
||||
case "broken", "scheduled":
|
||||
return S
|
||||
case "blocked":
|
||||
return "b"
|
||||
case "building":
|
||||
return "build"
|
||||
case "unresolvable":
|
||||
return "u"
|
||||
case "disabled":
|
||||
return "d"
|
||||
case "excluded":
|
||||
return "e"
|
||||
}
|
||||
return "un"
|
||||
}
|
||||
|
||||
svg.out.WriteString(`<text fill="#113" x="5ex" y="` + fmt.Sprint(svg.ypos-.6) + `em">` + html.EscapeString(arch) + `</text>`)
|
||||
svg.out.WriteString(`<g>`)
|
||||
if len(loglink) > 0 {
|
||||
u, err := url.Parse(loglink)
|
||||
if err == nil {
|
||||
svg.out.WriteString(`<a href="` + u.String() + `" target="_blank" rel="noopener">`)
|
||||
}
|
||||
}
|
||||
svg.out.WriteString(`<use href="#` + StatusToSVG(status) + `" x="20ex" y="` + fmt.Sprint(svg.ypos-1.7) + `em"/>`)
|
||||
if len(loglink) > 0 {
|
||||
svg.out.WriteString(`</a>`)
|
||||
}
|
||||
if len(detail) > 0 {
|
||||
svg.out.WriteString(`<title>` + html.EscapeString(detail) + "</title>")
|
||||
}
|
||||
|
||||
svg.out.WriteString("</g>\n")
|
||||
svg.ypos += 2
|
||||
}
|
||||
|
||||
func (svg *SvgWriter) WriteProjectStatus(project, repo, arch, status string, count int) {
|
||||
u, err := url.Parse(*ObsUrl + "/project/monitor/" + url.PathEscape(project) + "?defaults=0&" + url.QueryEscape(status) + "=1&arch_" + url.QueryEscape(arch) + "=1&repo_" + url.QueryEscape(strings.ReplaceAll(repo, ".", "_")) + "=1")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
svg.out.WriteString(`<g><a href="` + u.String() + `" target="_blank" rel="noopener">` + "\n" +
|
||||
`<text fill="#113" x="5ex" y="` + fmt.Sprint(svg.ypos-0.6) + "em\">\n" +
|
||||
html.EscapeString(status+": ") + fmt.Sprint(count) + "</text></a></g>\n")
|
||||
svg.ypos += 2
|
||||
}
|
||||
|
||||
func (svg *SvgWriter) GenerateSvg() []byte {
|
||||
return slices.Concat(svg.header, []byte(fmt.Sprint(svg.ypos)), svg.out.Bytes(), []byte("</svg>"))
|
||||
}
|
||||
24
reparent-bot/README.md
Normal file
24
reparent-bot/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
Reparent Bot
|
||||
============
|
||||
|
||||
To be able to put new parents of repositories as special forks into
|
||||
pool and other projects.
|
||||
|
||||
|
||||
Areas of Responsibilities
|
||||
-------------------------
|
||||
|
||||
* monitor issues for Add packages
|
||||
+ issue creator *must be* owner of the repo, OR
|
||||
+ repository must not be a fork
|
||||
* assign organization Owner to review request
|
||||
* reparent the repository and create a PR
|
||||
* remove non-accepted repositories from /pool, if no other
|
||||
branches are relevant here
|
||||
|
||||
Target Usage
|
||||
------------
|
||||
|
||||
* devel and released products
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ After=network-online.target
|
||||
[Service]
|
||||
Type=exec
|
||||
ExecStart=/usr/bin/gitea-events-rabbitmq-publisher
|
||||
EnvironmentFile=-/etc/sysconfig/gitea-events-rabbitmq-publisher.env
|
||||
EnvironmentFile=-/etc/default/gitea-events-rabbitmq-publisher.env
|
||||
DynamicUser=yes
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
|
||||
15
systemd/group-review@.service
Normal file
15
systemd/group-review@.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Group Review bot for %i
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
ExecStart=/usr/bin/group-review %i
|
||||
EnvironmentFile=-/etc/default/group-review/%i.env
|
||||
DynamicUser=yes
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
16
systemd/obs-staging-bot.service
Normal file
16
systemd/obs-staging-bot.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Staging bot for project git PRs in OBS
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
ExecStart=/usr/bin/obs-staging-bot
|
||||
EnvironmentFile=-/etc/default/obs-staging-bot.env
|
||||
DynamicUser=yes
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
|
||||
15
systemd/obs-status-service.service
Normal file
15
systemd/obs-status-service.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=OBS build status as SVG service
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
Restart=on-failure
|
||||
ExecStart=/usr/bin/obs-status-service
|
||||
EnvironmentFile=-/etc/default/obs-status-service.env
|
||||
DynamicUser=yes
|
||||
ProtectSystem=strict
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
19
systemd/workflow-direct@.service
Normal file
19
systemd/workflow-direct@.service
Normal file
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=WorkflowDirect git bot for %i
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
ExecStart=/usr/bin/workflow-direct
|
||||
EnvironmentFile=-/etc/default/%i/workflow-direct.env
|
||||
DynamicUser=yes
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
RuntimeDirectory=%i
|
||||
# SLES 15 doesn't have HOME set for dynamic users, so we improvise
|
||||
BindReadOnlyPaths=/etc/default/%i/known_hosts:/etc/ssh/ssh_known_hosts /etc/default/%i/config.json:%t/%i/config.json /etc/default/%i/id_ed25519 /etc/default/%i/id_ed25519.pub
|
||||
WorkingDirectory=%t/%i
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
4
test/main.go
Normal file
4
test/main.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package main
|
||||
|
||||
// exists only to import this for go.modules
|
||||
import "go.uber.org/mock/mockgen/model"
|
||||
27
utils/hujson/main.go
Normal file
27
utils/hujson/main.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
func main() {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
json, err := hujson.Standardize(data)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
os.Exit(2)
|
||||
}
|
||||
_, err = os.Stdout.Write(json)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
BIN
vendor.tar.zst
LFS
BIN
vendor.tar.zst
LFS
Binary file not shown.
15
vendor/github.com/asaskevich/govalidator/.gitignore
generated
vendored
Normal file
15
vendor/github.com/asaskevich/govalidator/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
bin/
|
||||
.idea/
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
12
vendor/github.com/asaskevich/govalidator/.travis.yml
generated
vendored
Normal file
12
vendor/github.com/asaskevich/govalidator/.travis.yml
generated
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
language: go
|
||||
dist: xenial
|
||||
go:
|
||||
- '1.10'
|
||||
- '1.11'
|
||||
- '1.12'
|
||||
- '1.13'
|
||||
- 'tip'
|
||||
|
||||
script:
|
||||
- go test -coverpkg=./... -coverprofile=coverage.info -timeout=5s
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user