SHA256
1
0

Compare commits

..

139 Commits

Author SHA256 Message Date
44bd6c23e5 . 2026-01-22 18:24:23 +01:00
17bfc36801 finish script 2026-01-22 18:21:19 +01:00
73d8c3e97f add parser 2026-01-22 18:12:20 +01:00
64e5b51488 check query prameters 2026-01-22 15:49:17 +01:00
1fe6bd98d4 Merge branch 'gitpkgs' of src.opensuse.org:git-workflow/autogits into gitpkgs 2026-01-22 14:22:14 +01:00
e859ed1b54 proper url parsing 2026-01-22 14:20:14 +01:00
1ad2e1738c more url parsing 2026-01-22 14:11:53 +01:00
a6d5d4c79d add ugly url parsing 2026-01-22 14:11:53 +01:00
f5d6f50b32 wip: git package updates 2026-01-22 14:11:53 +01:00
edd8c67fc9 obs-staging-bot: allow build-disabling repositories in the QA projects
Using the BuildDisableRepos configuration, it is now possible to
define which repositories to build-disable in the QA project meta.

This is for example useful for the SLES development workflow, where
the product repository should only be enabled after the packagelist
definitions have been built - so it is not desirable to have them
built as soon as the QA project is created.

Example:

    {
      "ObsProject": "SUSE:SLFO:Main",
      "StagingProject": "SUSE:SLFO:Main:PullRequest",
      "QA": [
        {
          "Name": "SLES",
          "Origin": "SUSE:SLFO:Products:SLES:16.1",
          "BuildDisableRepos": ["product"]
        }
      ]
    }

Signed-off-by: Eugenio Paolantonio <eugenio.paolantonio@suse.com>
2026-01-21 19:05:48 +01:00
87633e7508 more url parsing 2026-01-21 13:57:53 +01:00
56ce07514a add ugly url parsing 2026-01-21 12:58:48 +01:00
1d95b4cf0f wip: git package updates 2026-01-21 12:49:19 +01:00
cc69a9348c Merge commit 'refs/pull/109/head' of src.opensuse.org:git-workflow/autogits 2026-01-20 18:39:23 +01:00
5b5bb9a5bc group-review: fix test name 2026-01-19 13:41:05 +01:00
Antonello Tartamo
2f39fc9836 initial documentation review 2026-01-14 15:43:27 +01:00
38f4c44fd0 group-review: add more unit tests 2026-01-07 11:56:04 +01:00
605d3dee06 group-review: add notification unit tests 2026-01-07 11:32:04 +01:00
6f26bcdccc group-review: add mocked unit test 2026-01-07 10:42:39 +01:00
fffdf4fad3 group-review: add additional unit tests 2026-01-07 10:29:54 +01:00
f6d2239f4d group-review: move globals to struct
This enables easier testing
2026-01-07 10:27:12 +01:00
913fb7c046 group-review: add systemd file 2026-01-05 17:07:27 +01:00
79318dc169 group-review: add env variables
instead of using command-line parameters, we can use env variables
Very helpful for services.
2026-01-05 16:53:30 +01:00
377ed1c37f group-review: set correct comment text on negative review 2026-01-05 14:13:53 +01:00
51b0487b29 pr: parse ADD issue body
Validation of repository and org and other names is for the
consumer of this call. Maybe this can change later.
2025-12-16 18:33:22 +01:00
49e32c0ab1 PR: actually subscribe to PRComments
In previous fix attempt, there were changes to process IssueComments
but PRComments are their instance of IssueComments distinct from
IssueComments webhook events

Fixes: a418b48809
2025-12-12 16:41:25 +01:00
01e4f5f59e pr: fix error reporting for timeline PrjGit parse 2025-12-11 18:45:56 +01:00
19d9fc5f1e pr: request staging only when staging.config is there
If the project git does not have a staging.config, then there is
no reason to request reviews by the staging bot.
2025-12-11 17:01:15 +01:00
c4e184140a pr: handle case when reviews is nil 2025-12-09 17:19:38 +01:00
56c492ccdf PR: do not remove maintainer if also a reviewer
Maintainer review is only required if the PR is created by non-maintainer
or ProjetGit PR is created by non-bot. If maintainer review is not required,
but the maintainer is also listed as a Reviewer, then we cannot remove
this review request from the PR.
2025-12-08 18:03:00 +01:00
3a6009a5a3 pr: review requests cannot be stale
Yes, gitea marks these as stale too, but we should just ignore this
flag in case of requests. Stale requests are still pending.
2025-12-05 10:15:02 +01:00
2c4d25a5eb pr: remove maintainers if submitter is maintainer
If we have case where reviews are already there, for whatever reason
(eg. upgrade from older bot), remove the pending reviews if they
are not needed
2025-12-05 09:36:38 +01:00
052ab37412 common: Loading pending reviews when loading reviews 2025-12-04 19:02:21 +01:00
925f546272 pr: check all reviews, not just ones tagged reviewers 2025-12-04 18:21:32 +01:00
71fd32a707 pr: fix debug statements 2025-12-04 18:04:51 +01:00
581131bdc8 pr: request and unrequest reviewers
Move the function to request and unrequest reviewers to a different
function. This will allow later simplification of the function
that determines if all reviews are complete.

Unrequesting of reviews is only possible in case of bot issued
review requests. The rest are left as-is.
2025-12-03 18:55:10 +01:00
495ed349ea common: refactor: FetchPRSet also fetches Reviews 2025-12-03 18:55:10 +01:00
350a255d6e pr: allow to fetch reviews in PRSet loader 2025-12-03 18:55:10 +01:00
e3087e46c2 PR: skip maintainer review if not needed
If project or package maintainer already reviewed the PR, as
appropriate, they are no longer re-added to the PR. We also need
to remove reviewers, but only if they were previously requested
by the bot and not something else.
2025-12-03 18:55:10 +01:00
ae6b638df6 pr: handle case of … and ... elided title
Also do not change title of PR if not created by bot
2025-12-03 12:41:50 +01:00
2c73cc683a status: placeholder for factory sample data tests 2025-11-28 18:45:24 +01:00
32adfb1111 doc: fix table 2025-11-28 17:46:49 +01:00
fe8fcbae96 status: add test data 2025-11-28 17:22:12 +01:00
5756f7ceea pr: only update PR if elided title not changed
Gitea trims long titles so we need to compare if the trimmed length
is same, not entire string that will always differ.
2025-11-28 12:25:58 +01:00
2be0f808d2 pr: make sure issue list is consistent 2025-11-28 12:08:54 +01:00
7a0f651eaf direct: Gitea can send messages with no default branch
When a repository is created, there appears to be a race condition
where the default branch is not yet set in the message webhook
event.

We should additionally take care if the submodule is "registered"
but it wasn't correctly added, mostly due to earlier error. So,
always deinit submodules
2025-11-25 10:32:59 +01:00
2e47104b17 direct: improve commit messages in auto-updates 2025-11-24 12:58:46 +01:00
76bfa612c5 direct: use local branch name, instead of remote
If we fetch only one commit and force fetch the branch to local,
it seems that the remote head ref is not actually set. So, we should
just use the local version anyway, as it's updated.
2025-11-24 11:38:54 +01:00
71aa0813ad devel: sync migrated project list 2025-11-20 20:30:38 +01:00
cc675c1b24 devel: ignore dot files
magic hash is when no files exist and echo "" is passed to md5sum
2025-11-20 19:57:35 +01:00
44e4941120 devel: handle build.specials.obscpio 2025-11-20 19:40:49 +01:00
86acfa6871 pr: set staging auto label according to config
falls back to staging/Auto if nothing is set
2025-11-20 16:25:06 +01:00
7f09b2d2d3 common: match project config before packages
We need to cycle through all project configs before we try to
match non-project config branches/packages. If we have multiple
project gits in one org, this coudl match wrong config
2025-11-20 13:22:40 +01:00
f3a37f1158 pr: case fold 2025-11-19 19:32:55 +01:00
9d6db86318 pr: log case of no config in verification check 2025-11-19 17:10:08 +01:00
e11993c81f pr: do not stop processing if failed on some pacakge during check 2025-11-19 16:36:48 +01:00
4bd259a2a0 pr: ignore pkg PR if not open and no PrjGit PR 2025-11-19 16:35:17 +01:00
162ae11cdd common: init the cache so not null 2025-11-19 15:29:52 +01:00
8431b47322 group-review: allow dots in org and package names 2025-11-19 10:00:39 +01:00
3ed5ecc3f0 pr: add staging/Auto on new PRs
also, cache timeline fetches
2025-11-17 11:01:45 +01:00
d08ab3efd6 direct: support reverts 2025-11-13 23:52:05 +01:00
a4f6628e52 direct: always deinit, even if dirty 2025-11-13 23:51:18 +01:00
25073dd619 direct: use correct remote name for submodules
should be "origin"
2025-11-13 22:15:00 +01:00
4293181b4e pr: improve logging of review errors
when user is missing, log only the missing user
2025-11-12 21:39:26 +01:00
551a4ef577 direct: use origin for submodule checkout 2025-11-10 11:03:54 +01:00
6afb18fc58 direct: use correct repo for default branch 2025-11-10 10:24:33 +01:00
f310220261 direct: log default branch 2025-11-10 10:11:11 +01:00
ef7c0c1cea direct: fix debug logging 2025-11-10 09:42:43 +01:00
27230fa03b direct: fix debug logging 2025-11-10 09:34:10 +01:00
c52d40b760 direct: explicit path for config bind 2025-11-09 23:26:04 +01:00
d3ba579a8b common: fix systemd execution
In case when we are running under older systemd that does not set
transient home, we need to improvise when connecting via SSH
and passing the identity file explicitly
2025-11-09 23:10:08 +01:00
9ef8209622 direct: bind config to working directory
Use temp /run instance directory for the config
Use ./config.json as default from within the process
2025-11-07 17:06:27 +01:00
ba66dd868e direct: fix running bot without any config params
Only use env variables.
2025-11-07 16:19:13 +01:00
17755fa2b5 status: fix repo links for monitor page 2025-11-07 13:59:52 +01:00
f94d3a8942 status: update README 2025-11-05 16:56:30 +01:00
20e1109602 spec: packaging fixes
* Update Version to 1, since we now have devel project and updates
should have version bump instead of downgrade
* other fixes
2025-11-05 16:38:15 +01:00
c25d3be44e direct: add systemd unit file 2025-11-05 13:24:54 +01:00
8db558891a direct: remove config.Branch clobbering
use our own copy of branch instead of writing it in the config.
This should fix handling of default branches where the default
branch differs between repositories.
2025-11-04 18:00:21 +01:00
0e06ba5993 common: classifying rm branches on name
Branches with suffixes

  -rm
  -removed
  -deleted

are now classified as removed. This is important in case project
config refers to default branch names which must exist so we need
to be able to classify such branches to either use them or ignore
them
2025-11-04 18:00:21 +01:00
736769d630 direct: add a repo with branch but no submodule 2025-11-04 18:00:21 +01:00
93c970d0dd direct: move logging to common.Log* function 2025-11-04 18:00:21 +01:00
5544a65947 obs-staging-bot: Expand possible branch of QA repos
That way a source merge of any product is not triggering rebuilds in
pull request QA sub projects. We may need a config option here to
enable/disable this.
2025-11-03 17:54:57 +01:00
918723d57b Merge commit '55846562c1d9dcb395e545f7c8e0bcb74c47b85693f4e955ef488530781b9bf2'
PR!88
2025-11-03 17:49:45 +01:00
a418b48809 pr: process PR on comments, not issue changes 2025-10-31 13:07:18 +01:00
55846562c1 Add simple readme for gitea_status_proxy 2025-10-31 10:33:58 +01:00
95c7770cad Change log level for auth errors 2025-10-31 10:33:58 +01:00
1b900e3202 Properly proxy json input directly to gitea 2025-10-31 10:33:51 +01:00
d083acfd1c Be more verbose about authentication errors 2025-10-30 13:09:17 +01:00
244160e20e Update authorization headers
For gitea API AuthorizationHeaderToken tokens must be prepended with "token" followed by a space, also fix content type
2025-10-30 13:09:17 +01:00
ed2847a2c6 README: devel-project follows main
And use home:adamm:autogits/autogits for building staging branch, if any
2025-10-28 12:54:22 +01:00
1457caa64b Merge branch 'main' of src.opensuse.org:git-workflow/autogits 2025-10-28 12:51:12 +01:00
b9a38c1724 Update README.md 2025-10-28 12:09:10 +01:00
74edad5d3e devel: fix pool setting script 2025-10-28 11:32:51 +01:00
Jan Zerebecki
e5cad365ee Run go tests from rpm check
Skip some failing tests to be able to run the rest.
Add missing config to make git commit succeed inside rpmbuild.
2025-10-27 14:42:21 +01:00
Jan Zerebecki
53851ba10f Add ci jobs for go vendor 2025-10-24 11:45:41 +02:00
Jan Zerebecki
056e5208c8 Add ci jobs for go generate
either to check it produces no diff, or to manually trigger pushing any
diff as a commmit to the current branch.
2025-10-24 11:45:38 +02:00
Jan Zerebecki
af142fdb15 Prefix packages and build rest
Add some dependencies from Containerfiles, so containers can be built
from the rpm and implicitly pull those in.
Build some binaries that where added since and add sub-package for them.
2025-10-24 11:22:52 +02:00
Jan Zerebecki
5ce92beb52 Add go generate result 2025-10-24 10:39:34 +02:00
Gitea Actions
ae379ec408 CI run result of: go mod vendor 2025-10-24 10:39:31 +02:00
458837b007 status: complete the fix for insufficient Clone()
Ammends: 58d1f2de91
2025-10-15 18:46:46 +02:00
a3feab6f7e status: use obs+xml mime header instead of xml
browsers request application/xml on their Accept: headers by default,
which causes status to return XML to them instead of falling back
to SVG
2025-10-15 18:42:52 +02:00
fa647ab2d8 status: do not marshall empty XMLName in json 2025-10-15 18:24:52 +02:00
19902813b5 status: fix xml output 2025-10-15 18:09:21 +02:00
23a7f310c5 status: Add application/xml support in output 2025-10-15 16:59:52 +02:00
58d1f2de91 status: make a copy before overwriting 2025-10-14 19:37:36 +02:00
d623844411 pr: do not fail checkout 2025-10-13 18:37:42 +02:00
04825b552e pr: use force-merge instead of force-push
The permission is to accept a change without required reviews, not
to actually force-push

Fixes 7bad8eb5a9
2025-10-12 10:22:49 +02:00
ca7966f3e0 pr: sanity check
make sure that the checked out PR matches what Gitea is sending
us, otherwise pause for a few seconds and retry.
2025-10-11 18:10:15 +02:00
0c47ca4d32 pr: updating PR needs to update the SHA
If we are updating a Project Git PR, we need to save the updated
hash or we may be lookign at pre-update PR for various operations,
including merging.

This mostly only affects project gits in devel projects where
the project git could be updated by direct workflow bot, but then
the project is incorrectly resulting in no package update.
2025-10-11 17:25:48 +02:00
7bad8eb5a9 pr: Add config definitions for permission set 2025-10-09 18:43:56 +02:00
c2c60b77e5 use autogits package prefix 2025-10-08 13:03:06 +02:00
76b5a5dc0d import: factory hash setting utility 2025-10-07 22:55:58 +02:00
58da491049 common: handle translation to SSH if already SSH 2025-10-07 17:26:27 +02:00
626bead304 status: improve request logging 2025-10-06 14:07:35 +02:00
30bac996f4 status: redundant entry in service file 2025-10-06 14:03:38 +02:00
9adc718b6f spec: hujson moved to utils subpackage 2025-10-06 13:52:39 +02:00
070f45bc25 status: add no lock function
Locking is not re-entrant, so these are useful if we need
to find things while we already lock the strctures
2025-10-06 13:49:19 +02:00
d061f29699 status: use env as parameters to service
Instead of having to rewrite the service file with parameters,
leverage Env file to pass default parameters values.
2025-10-06 13:49:12 +02:00
f6fd96881d staging: improve docs 2025-10-02 17:40:00 +02:00
2be785676a reparent: add readme 2025-10-02 17:05:34 +02:00
1b9ee2d46a PR: ref requries PR fetch, and not in timeline 2025-10-02 15:13:43 +02:00
b7bbafacf8 PR: limit search to bot account for ProjectGit PRs 2025-10-02 13:45:31 +02:00
240896f101 status: fix delete function logic 2025-10-01 19:35:43 +02:00
a7b326fceb status: limit results to specific packages 2025-10-01 19:28:47 +02:00
76ed03f86f status: add json output support
if Accept: application/json is present, return JSON output
of build results instead of SVG
2025-10-01 18:58:08 +02:00
1af2f53755 PR: Fix case where PR repo != target repo
Was using a check that the label has the repo name in it, but
this is not always reliable. So, check repo.ID if it's the same.
2025-10-01 15:33:39 +02:00
0de9071f92 group-review: we need to clone before modifying a slice 2025-09-30 17:27:36 +02:00
855faea659 imported devel:openSUSE:Factory 2025-09-29 15:10:25 +02:00
dbd581ffef import: packages are not just in factory
Some packages share names of openSUSE:Factory packages but actually
have nothing in common with them. So before importing the Factory
package, check if the package is actually a devel project for Factory
and only proceed if it is. Otherwise, assume that the devel
project package is independent.
2025-09-29 15:08:30 +02:00
1390225614 PR: list missing PRs in the logs 2025-09-29 14:58:43 +02:00
a03491f75c Keep maintainers from staging template project
They need to keep access as they might need to be able to modify the
stage project. They could grant access anyway, by adding themselfs
as they own the upper project. No reason to force them the
extra trip or to hide build results first to them
2025-09-24 10:39:07 +02:00
2092fc4f42 Fix handling of all project flags
We skipped access and sourceaccess flags before
2025-09-24 09:33:29 +02:00
d2973f4792 PR: only consider open PR when creating new PRs 2025-09-21 23:21:40 +02:00
58022c6edc update transition project list 2025-09-21 20:21:15 +02:00
994e6b3ca2 status: fix typo regression 2025-09-18 19:18:03 +02:00
6414336ee6 status: add basic project level build results 2025-09-18 19:05:35 +02:00
1104581eb6 status: superflous Sprintf 2025-09-18 16:50:59 +02:00
6ad110e5d3 status: escape strings 2025-09-18 16:30:34 +02:00
e39ce302b8 status: fix README 2025-09-18 13:07:08 +02:00
60 changed files with 4573 additions and 1067 deletions

View 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

View 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

View 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

View 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

3
.gitignore vendored
View File

@@ -1,5 +1,2 @@
node_modules
*.obscpio
autogits-tmp.tar.zst
*.osc
*.conf

View File

@@ -23,18 +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
------------
main branch build status:
![Devel Build Status](https://br.opensuse.org/status/home:adamm:autogits/autogits)
Devel project build status:
Devel project build status (`main` branch):
![devel:Factory:git-workflow](https://br.opensuse.org/status/devel:Factory:git-workflow/autogits)
`staging` branch build status:
![Staging Build Status](https://br.opensuse.org/status/home:adamm:autogits/autogits)

View File

@@ -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>

View File

@@ -17,11 +17,12 @@
Name: autogits
Version: 0
Version: 1
Release: 0
Summary: GitWorkflow utilities
License: GPL-2.0-or-later
URL: https://src.opensuse.org/adamm/autogits
BuildRequires: git
BuildRequires: systemd-rpm-macros
BuildRequires: go
%{?systemd_ordering}
@@ -30,61 +31,90 @@ BuildRequires: go
Git Workflow tooling and utilities enabling automated handing of OBS projects
as git repositories
%package -n hujson
Summary: HuJSON to JSON parser
%description -n hujson
HuJSON to JSON parser, using stdin -> stdout pipe
%package devel-importer
Summary: Imports devel projects from obs to git
%package -n gitea-events-rabbitmq-publisher
%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 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
@@ -94,14 +124,23 @@ cp -r /home/abuild/rpmbuild/SOURCES/* ./
%build
go build \
-C hujson \
-C devel-importer \
-buildmode=pie
go build \
-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 \
-buildmode=pie
go build \
-C obs-forward-bot \
-buildmode=pie
go build \
-C obs-staging-bot \
-buildmode=pie
@@ -115,78 +154,145 @@ 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 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 -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 hujson/hujson %{buildroot}%{_bindir}/hujson
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
%pre -n obs-staging-bot
%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 -n obs-staging-bot
%post obs-staging-bot
%service_add_post obs-staging-bot.service
%preun -n obs-staging-bot
%preun obs-staging-bot
%service_del_preun obs-staging-bot.service
%postun -n obs-staging-bot
%postun obs-staging-bot
%service_del_postun obs-staging-bot.service
%files -n gitea-events-rabbitmq-publisher
%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 group-review
%files group-review
%license COPYING
%doc group-review/README.md
%{_bindir}/group-review
%{_unitdir}/group-review@.service
%files -n hujson
%files obs-forward-bot
%license COPYING
%{_bindir}/hujson
%{_bindir}/obs-forward-bot
%files -n obs-staging-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
%{_unitdir}/workflow-direct@.service
%files -n workflow-pr
%files workflow-pr
%license COPYING
%doc workflow-pr/README.md
%{_bindir}/workflow-pr

View File

@@ -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 {
@@ -50,18 +54,41 @@ type ReviewGroup struct {
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
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
@@ -178,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
}
@@ -186,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 {
@@ -242,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
@@ -255,7 +313,7 @@ type StagingConfig struct {
func ParseStagingConfig(data []byte) (*StagingConfig, error) {
var staging StagingConfig
if len(data) == 0 {
return nil, errors.New("non-existent config file.")
return nil, errors.New("non-existent config file.")
}
data, err := hujson.Standardize(data)
if err != nil {

View File

@@ -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,6 +189,10 @@ 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")
}
})
}
}
@@ -190,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")
}
})
}
}

View File

@@ -277,7 +277,7 @@ func (e *GitHandlerImpl) GitClone(repo, branch, remoteUrl string) (string, error
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) {
@@ -350,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",
@@ -358,7 +362,7 @@ func (e *GitHandlerImpl) GitExecWithOutput(cwd string, params ...string) (string
"EMAIL=not@exist@src.opensuse.org",
"GIT_LFS_SKIP_SMUDGE=1",
"GIT_LFS_SKIP_PUSH=1",
"GIT_SSH_COMMAND=/usr/bin/ssh -o StrictHostKeyChecking=yes",
"GIT_SSH_COMMAND=/usr/bin/ssh -o StrictHostKeyChecking=yes" + identityFile,
}
if len(ExtraGitParams) > 0 {
cmd.Env = append(cmd.Env, ExtraGitParams...)

View File

@@ -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())
}

View File

@@ -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 {
@@ -182,6 +198,8 @@ type Gitea interface {
GiteaCommitStatusGetter
GiteaCommitStatusSetter
GiteaSetRepoOptions
GiteaLabelGetter
GiteaLabelSettter
GetNotifications(Type string, since *time.Time) ([]*models.NotificationThread, error)
GetDoneNotifications(Type string, page int64) ([]*models.NotificationThread, error)
@@ -189,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)
@@ -466,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"
)
@@ -643,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,
@@ -658,8 +700,8 @@ func (gitea *GiteaTransport) CreatePullRequestIfNotExist(repo *models.Repository
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(
@@ -673,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) {
@@ -763,45 +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 == 0 {
if resCount = len(res.Payload); resCount == 0 {
break
}
page++
for _, d := range res.Payload {
if d != nil {
retData = append(retData, d)
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++
}
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) {

View File

@@ -18,6 +18,132 @@ import (
models "src.opensuse.org/autogits/common/gitea-generated/models"
)
// MockGiteaLabelGetter is a mock of GiteaLabelGetter interface.
type MockGiteaLabelGetter struct {
ctrl *gomock.Controller
recorder *MockGiteaLabelGetterMockRecorder
isgomock struct{}
}
// MockGiteaLabelGetterMockRecorder is the mock recorder for MockGiteaLabelGetter.
type MockGiteaLabelGetterMockRecorder struct {
mock *MockGiteaLabelGetter
}
// NewMockGiteaLabelGetter creates a new mock instance.
func NewMockGiteaLabelGetter(ctrl *gomock.Controller) *MockGiteaLabelGetter {
mock := &MockGiteaLabelGetter{ctrl: ctrl}
mock.recorder = &MockGiteaLabelGetterMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGiteaLabelGetter) EXPECT() *MockGiteaLabelGetterMockRecorder {
return m.recorder
}
// GetLabels mocks base method.
func (m *MockGiteaLabelGetter) GetLabels(org, repo string, idx int64) ([]*models.Label, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLabels", org, repo, idx)
ret0, _ := ret[0].([]*models.Label)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetLabels indicates an expected call of GetLabels.
func (mr *MockGiteaLabelGetterMockRecorder) GetLabels(org, repo, idx any) *MockGiteaLabelGetterGetLabelsCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLabels", reflect.TypeOf((*MockGiteaLabelGetter)(nil).GetLabels), org, repo, idx)
return &MockGiteaLabelGetterGetLabelsCall{Call: call}
}
// MockGiteaLabelGetterGetLabelsCall wrap *gomock.Call
type MockGiteaLabelGetterGetLabelsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaLabelGetterGetLabelsCall) Return(arg0 []*models.Label, arg1 error) *MockGiteaLabelGetterGetLabelsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaLabelGetterGetLabelsCall) Do(f func(string, string, int64) ([]*models.Label, error)) *MockGiteaLabelGetterGetLabelsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaLabelGetterGetLabelsCall) DoAndReturn(f func(string, string, int64) ([]*models.Label, error)) *MockGiteaLabelGetterGetLabelsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGiteaLabelSettter is a mock of GiteaLabelSettter interface.
type MockGiteaLabelSettter struct {
ctrl *gomock.Controller
recorder *MockGiteaLabelSettterMockRecorder
isgomock struct{}
}
// MockGiteaLabelSettterMockRecorder is the mock recorder for MockGiteaLabelSettter.
type MockGiteaLabelSettterMockRecorder struct {
mock *MockGiteaLabelSettter
}
// NewMockGiteaLabelSettter creates a new mock instance.
func NewMockGiteaLabelSettter(ctrl *gomock.Controller) *MockGiteaLabelSettter {
mock := &MockGiteaLabelSettter{ctrl: ctrl}
mock.recorder = &MockGiteaLabelSettterMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGiteaLabelSettter) EXPECT() *MockGiteaLabelSettterMockRecorder {
return m.recorder
}
// SetLabels mocks base method.
func (m *MockGiteaLabelSettter) SetLabels(org, repo string, idx int64, labels []string) ([]*models.Label, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetLabels", org, repo, idx, labels)
ret0, _ := ret[0].([]*models.Label)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SetLabels indicates an expected call of SetLabels.
func (mr *MockGiteaLabelSettterMockRecorder) SetLabels(org, repo, idx, labels any) *MockGiteaLabelSettterSetLabelsCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLabels", reflect.TypeOf((*MockGiteaLabelSettter)(nil).SetLabels), org, repo, idx, labels)
return &MockGiteaLabelSettterSetLabelsCall{Call: call}
}
// MockGiteaLabelSettterSetLabelsCall wrap *gomock.Call
type MockGiteaLabelSettterSetLabelsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaLabelSettterSetLabelsCall) Return(arg0 []*models.Label, arg1 error) *MockGiteaLabelSettterSetLabelsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaLabelSettterSetLabelsCall) Do(f func(string, string, int64, []string) ([]*models.Label, error)) *MockGiteaLabelSettterSetLabelsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaLabelSettterSetLabelsCall) DoAndReturn(f func(string, string, int64, []string) ([]*models.Label, error)) *MockGiteaLabelSettterSetLabelsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGiteaTimelineFetcher is a mock of GiteaTimelineFetcher interface.
type MockGiteaTimelineFetcher struct {
ctrl *gomock.Controller
@@ -436,32 +562,32 @@ func (c *MockGiteaPRUpdaterUpdatePullRequestCall) DoAndReturn(f func(string, str
return c
}
// MockGiteaPRTimelineFetcher is a mock of GiteaPRTimelineFetcher interface.
type MockGiteaPRTimelineFetcher struct {
// MockGiteaPRTimelineReviewFetcher is a mock of GiteaPRTimelineReviewFetcher interface.
type MockGiteaPRTimelineReviewFetcher struct {
ctrl *gomock.Controller
recorder *MockGiteaPRTimelineFetcherMockRecorder
recorder *MockGiteaPRTimelineReviewFetcherMockRecorder
isgomock struct{}
}
// MockGiteaPRTimelineFetcherMockRecorder is the mock recorder for MockGiteaPRTimelineFetcher.
type MockGiteaPRTimelineFetcherMockRecorder struct {
mock *MockGiteaPRTimelineFetcher
// MockGiteaPRTimelineReviewFetcherMockRecorder is the mock recorder for MockGiteaPRTimelineReviewFetcher.
type MockGiteaPRTimelineReviewFetcherMockRecorder struct {
mock *MockGiteaPRTimelineReviewFetcher
}
// NewMockGiteaPRTimelineFetcher creates a new mock instance.
func NewMockGiteaPRTimelineFetcher(ctrl *gomock.Controller) *MockGiteaPRTimelineFetcher {
mock := &MockGiteaPRTimelineFetcher{ctrl: ctrl}
mock.recorder = &MockGiteaPRTimelineFetcherMockRecorder{mock}
// NewMockGiteaPRTimelineReviewFetcher creates a new mock instance.
func NewMockGiteaPRTimelineReviewFetcher(ctrl *gomock.Controller) *MockGiteaPRTimelineReviewFetcher {
mock := &MockGiteaPRTimelineReviewFetcher{ctrl: ctrl}
mock.recorder = &MockGiteaPRTimelineReviewFetcherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGiteaPRTimelineFetcher) EXPECT() *MockGiteaPRTimelineFetcherMockRecorder {
func (m *MockGiteaPRTimelineReviewFetcher) EXPECT() *MockGiteaPRTimelineReviewFetcherMockRecorder {
return m.recorder
}
// GetPullRequest mocks base method.
func (m *MockGiteaPRTimelineFetcher) GetPullRequest(org, project string, num int64) (*models.PullRequest, error) {
func (m *MockGiteaPRTimelineReviewFetcher) GetPullRequest(org, project string, num int64) (*models.PullRequest, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPullRequest", org, project, num)
ret0, _ := ret[0].(*models.PullRequest)
@@ -470,37 +596,76 @@ func (m *MockGiteaPRTimelineFetcher) GetPullRequest(org, project string, num int
}
// GetPullRequest indicates an expected call of GetPullRequest.
func (mr *MockGiteaPRTimelineFetcherMockRecorder) GetPullRequest(org, project, num any) *MockGiteaPRTimelineFetcherGetPullRequestCall {
func (mr *MockGiteaPRTimelineReviewFetcherMockRecorder) GetPullRequest(org, project, num any) *MockGiteaPRTimelineReviewFetcherGetPullRequestCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequest", reflect.TypeOf((*MockGiteaPRTimelineFetcher)(nil).GetPullRequest), org, project, num)
return &MockGiteaPRTimelineFetcherGetPullRequestCall{Call: call}
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequest", reflect.TypeOf((*MockGiteaPRTimelineReviewFetcher)(nil).GetPullRequest), org, project, num)
return &MockGiteaPRTimelineReviewFetcherGetPullRequestCall{Call: call}
}
// MockGiteaPRTimelineFetcherGetPullRequestCall wrap *gomock.Call
type MockGiteaPRTimelineFetcherGetPullRequestCall struct {
// MockGiteaPRTimelineReviewFetcherGetPullRequestCall wrap *gomock.Call
type MockGiteaPRTimelineReviewFetcherGetPullRequestCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaPRTimelineFetcherGetPullRequestCall) Return(arg0 *models.PullRequest, arg1 error) *MockGiteaPRTimelineFetcherGetPullRequestCall {
func (c *MockGiteaPRTimelineReviewFetcherGetPullRequestCall) Return(arg0 *models.PullRequest, arg1 error) *MockGiteaPRTimelineReviewFetcherGetPullRequestCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaPRTimelineFetcherGetPullRequestCall) Do(f func(string, string, int64) (*models.PullRequest, error)) *MockGiteaPRTimelineFetcherGetPullRequestCall {
func (c *MockGiteaPRTimelineReviewFetcherGetPullRequestCall) Do(f func(string, string, int64) (*models.PullRequest, error)) *MockGiteaPRTimelineReviewFetcherGetPullRequestCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaPRTimelineFetcherGetPullRequestCall) DoAndReturn(f func(string, string, int64) (*models.PullRequest, error)) *MockGiteaPRTimelineFetcherGetPullRequestCall {
func (c *MockGiteaPRTimelineReviewFetcherGetPullRequestCall) DoAndReturn(f func(string, string, int64) (*models.PullRequest, error)) *MockGiteaPRTimelineReviewFetcherGetPullRequestCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// GetPullRequestReviews mocks base method.
func (m *MockGiteaPRTimelineReviewFetcher) GetPullRequestReviews(org, project string, PRnum int64) ([]*models.PullReview, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPullRequestReviews", org, project, PRnum)
ret0, _ := ret[0].([]*models.PullReview)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetPullRequestReviews indicates an expected call of GetPullRequestReviews.
func (mr *MockGiteaPRTimelineReviewFetcherMockRecorder) GetPullRequestReviews(org, project, PRnum any) *MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequestReviews", reflect.TypeOf((*MockGiteaPRTimelineReviewFetcher)(nil).GetPullRequestReviews), org, project, PRnum)
return &MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall{Call: call}
}
// MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall wrap *gomock.Call
type MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall) Return(arg0 []*models.PullReview, arg1 error) *MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall) Do(f func(string, string, int64) ([]*models.PullReview, error)) *MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall) DoAndReturn(f func(string, string, int64) ([]*models.PullReview, error)) *MockGiteaPRTimelineReviewFetcherGetPullRequestReviewsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// GetTimeline mocks base method.
func (m *MockGiteaPRTimelineFetcher) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) {
func (m *MockGiteaPRTimelineReviewFetcher) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTimeline", org, repo, idx)
ret0, _ := ret[0].([]*models.TimelineComment)
@@ -509,31 +674,31 @@ func (m *MockGiteaPRTimelineFetcher) GetTimeline(org, repo string, idx int64) ([
}
// GetTimeline indicates an expected call of GetTimeline.
func (mr *MockGiteaPRTimelineFetcherMockRecorder) GetTimeline(org, repo, idx any) *MockGiteaPRTimelineFetcherGetTimelineCall {
func (mr *MockGiteaPRTimelineReviewFetcherMockRecorder) GetTimeline(org, repo, idx any) *MockGiteaPRTimelineReviewFetcherGetTimelineCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTimeline", reflect.TypeOf((*MockGiteaPRTimelineFetcher)(nil).GetTimeline), org, repo, idx)
return &MockGiteaPRTimelineFetcherGetTimelineCall{Call: call}
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTimeline", reflect.TypeOf((*MockGiteaPRTimelineReviewFetcher)(nil).GetTimeline), org, repo, idx)
return &MockGiteaPRTimelineReviewFetcherGetTimelineCall{Call: call}
}
// MockGiteaPRTimelineFetcherGetTimelineCall wrap *gomock.Call
type MockGiteaPRTimelineFetcherGetTimelineCall struct {
// MockGiteaPRTimelineReviewFetcherGetTimelineCall wrap *gomock.Call
type MockGiteaPRTimelineReviewFetcherGetTimelineCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaPRTimelineFetcherGetTimelineCall) Return(arg0 []*models.TimelineComment, arg1 error) *MockGiteaPRTimelineFetcherGetTimelineCall {
func (c *MockGiteaPRTimelineReviewFetcherGetTimelineCall) Return(arg0 []*models.TimelineComment, arg1 error) *MockGiteaPRTimelineReviewFetcherGetTimelineCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaPRTimelineFetcherGetTimelineCall) Do(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaPRTimelineFetcherGetTimelineCall {
func (c *MockGiteaPRTimelineReviewFetcherGetTimelineCall) Do(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaPRTimelineReviewFetcherGetTimelineCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaPRTimelineFetcherGetTimelineCall) DoAndReturn(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaPRTimelineFetcherGetTimelineCall {
func (c *MockGiteaPRTimelineReviewFetcherGetTimelineCall) DoAndReturn(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaPRTimelineReviewFetcherGetTimelineCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
@@ -1050,32 +1215,32 @@ func (c *MockGiteaPRCheckerGetTimelineCall) DoAndReturn(f func(string, string, i
return c
}
// MockGiteaReviewFetcherAndRequester is a mock of GiteaReviewFetcherAndRequester interface.
type MockGiteaReviewFetcherAndRequester struct {
// MockGiteaReviewFetcherAndRequesterAndUnrequester is a mock of GiteaReviewFetcherAndRequesterAndUnrequester interface.
type MockGiteaReviewFetcherAndRequesterAndUnrequester struct {
ctrl *gomock.Controller
recorder *MockGiteaReviewFetcherAndRequesterMockRecorder
recorder *MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder
isgomock struct{}
}
// MockGiteaReviewFetcherAndRequesterMockRecorder is the mock recorder for MockGiteaReviewFetcherAndRequester.
type MockGiteaReviewFetcherAndRequesterMockRecorder struct {
mock *MockGiteaReviewFetcherAndRequester
// MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder is the mock recorder for MockGiteaReviewFetcherAndRequesterAndUnrequester.
type MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder struct {
mock *MockGiteaReviewFetcherAndRequesterAndUnrequester
}
// NewMockGiteaReviewFetcherAndRequester creates a new mock instance.
func NewMockGiteaReviewFetcherAndRequester(ctrl *gomock.Controller) *MockGiteaReviewFetcherAndRequester {
mock := &MockGiteaReviewFetcherAndRequester{ctrl: ctrl}
mock.recorder = &MockGiteaReviewFetcherAndRequesterMockRecorder{mock}
// NewMockGiteaReviewFetcherAndRequesterAndUnrequester creates a new mock instance.
func NewMockGiteaReviewFetcherAndRequesterAndUnrequester(ctrl *gomock.Controller) *MockGiteaReviewFetcherAndRequesterAndUnrequester {
mock := &MockGiteaReviewFetcherAndRequesterAndUnrequester{ctrl: ctrl}
mock.recorder = &MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGiteaReviewFetcherAndRequester) EXPECT() *MockGiteaReviewFetcherAndRequesterMockRecorder {
func (m *MockGiteaReviewFetcherAndRequesterAndUnrequester) EXPECT() *MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder {
return m.recorder
}
// GetIssueComments mocks base method.
func (m *MockGiteaReviewFetcherAndRequester) GetIssueComments(org, project string, issueNo int64) ([]*models.Comment, error) {
func (m *MockGiteaReviewFetcherAndRequesterAndUnrequester) GetIssueComments(org, project string, issueNo int64) ([]*models.Comment, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetIssueComments", org, project, issueNo)
ret0, _ := ret[0].([]*models.Comment)
@@ -1084,37 +1249,37 @@ func (m *MockGiteaReviewFetcherAndRequester) GetIssueComments(org, project strin
}
// GetIssueComments indicates an expected call of GetIssueComments.
func (mr *MockGiteaReviewFetcherAndRequesterMockRecorder) GetIssueComments(org, project, issueNo any) *MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall {
func (mr *MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder) GetIssueComments(org, project, issueNo any) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIssueComments", reflect.TypeOf((*MockGiteaReviewFetcherAndRequester)(nil).GetIssueComments), org, project, issueNo)
return &MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall{Call: call}
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIssueComments", reflect.TypeOf((*MockGiteaReviewFetcherAndRequesterAndUnrequester)(nil).GetIssueComments), org, project, issueNo)
return &MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall{Call: call}
}
// MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall struct {
// MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall) Return(arg0 []*models.Comment, arg1 error) *MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall) Return(arg0 []*models.Comment, arg1 error) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall) Do(f func(string, string, int64) ([]*models.Comment, error)) *MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall) Do(f func(string, string, int64) ([]*models.Comment, error)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall) DoAndReturn(f func(string, string, int64) ([]*models.Comment, error)) *MockGiteaReviewFetcherAndRequesterGetIssueCommentsCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall) DoAndReturn(f func(string, string, int64) ([]*models.Comment, error)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetIssueCommentsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// GetPullRequestReviews mocks base method.
func (m *MockGiteaReviewFetcherAndRequester) GetPullRequestReviews(org, project string, PRnum int64) ([]*models.PullReview, error) {
func (m *MockGiteaReviewFetcherAndRequesterAndUnrequester) GetPullRequestReviews(org, project string, PRnum int64) ([]*models.PullReview, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPullRequestReviews", org, project, PRnum)
ret0, _ := ret[0].([]*models.PullReview)
@@ -1123,37 +1288,37 @@ func (m *MockGiteaReviewFetcherAndRequester) GetPullRequestReviews(org, project
}
// GetPullRequestReviews indicates an expected call of GetPullRequestReviews.
func (mr *MockGiteaReviewFetcherAndRequesterMockRecorder) GetPullRequestReviews(org, project, PRnum any) *MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall {
func (mr *MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder) GetPullRequestReviews(org, project, PRnum any) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequestReviews", reflect.TypeOf((*MockGiteaReviewFetcherAndRequester)(nil).GetPullRequestReviews), org, project, PRnum)
return &MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall{Call: call}
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequestReviews", reflect.TypeOf((*MockGiteaReviewFetcherAndRequesterAndUnrequester)(nil).GetPullRequestReviews), org, project, PRnum)
return &MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall{Call: call}
}
// MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall struct {
// MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall) Return(arg0 []*models.PullReview, arg1 error) *MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall) Return(arg0 []*models.PullReview, arg1 error) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall) Do(f func(string, string, int64) ([]*models.PullReview, error)) *MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall) Do(f func(string, string, int64) ([]*models.PullReview, error)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall) DoAndReturn(f func(string, string, int64) ([]*models.PullReview, error)) *MockGiteaReviewFetcherAndRequesterGetPullRequestReviewsCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall) DoAndReturn(f func(string, string, int64) ([]*models.PullReview, error)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetPullRequestReviewsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// GetTimeline mocks base method.
func (m *MockGiteaReviewFetcherAndRequester) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) {
func (m *MockGiteaReviewFetcherAndRequesterAndUnrequester) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTimeline", org, repo, idx)
ret0, _ := ret[0].([]*models.TimelineComment)
@@ -1162,37 +1327,37 @@ func (m *MockGiteaReviewFetcherAndRequester) GetTimeline(org, repo string, idx i
}
// GetTimeline indicates an expected call of GetTimeline.
func (mr *MockGiteaReviewFetcherAndRequesterMockRecorder) GetTimeline(org, repo, idx any) *MockGiteaReviewFetcherAndRequesterGetTimelineCall {
func (mr *MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder) GetTimeline(org, repo, idx any) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTimeline", reflect.TypeOf((*MockGiteaReviewFetcherAndRequester)(nil).GetTimeline), org, repo, idx)
return &MockGiteaReviewFetcherAndRequesterGetTimelineCall{Call: call}
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTimeline", reflect.TypeOf((*MockGiteaReviewFetcherAndRequesterAndUnrequester)(nil).GetTimeline), org, repo, idx)
return &MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall{Call: call}
}
// MockGiteaReviewFetcherAndRequesterGetTimelineCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterGetTimelineCall struct {
// MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaReviewFetcherAndRequesterGetTimelineCall) Return(arg0 []*models.TimelineComment, arg1 error) *MockGiteaReviewFetcherAndRequesterGetTimelineCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall) Return(arg0 []*models.TimelineComment, arg1 error) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaReviewFetcherAndRequesterGetTimelineCall) Do(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaReviewFetcherAndRequesterGetTimelineCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall) Do(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaReviewFetcherAndRequesterGetTimelineCall) DoAndReturn(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaReviewFetcherAndRequesterGetTimelineCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall) DoAndReturn(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterGetTimelineCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// RequestReviews mocks base method.
func (m *MockGiteaReviewFetcherAndRequester) RequestReviews(pr *models.PullRequest, reviewer ...string) ([]*models.PullReview, error) {
func (m *MockGiteaReviewFetcherAndRequesterAndUnrequester) RequestReviews(pr *models.PullRequest, reviewer ...string) ([]*models.PullReview, error) {
m.ctrl.T.Helper()
varargs := []any{pr}
for _, a := range reviewer {
@@ -1205,32 +1370,181 @@ func (m *MockGiteaReviewFetcherAndRequester) RequestReviews(pr *models.PullReque
}
// RequestReviews indicates an expected call of RequestReviews.
func (mr *MockGiteaReviewFetcherAndRequesterMockRecorder) RequestReviews(pr any, reviewer ...any) *MockGiteaReviewFetcherAndRequesterRequestReviewsCall {
func (mr *MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder) RequestReviews(pr any, reviewer ...any) *MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall {
mr.mock.ctrl.T.Helper()
varargs := append([]any{pr}, reviewer...)
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestReviews", reflect.TypeOf((*MockGiteaReviewFetcherAndRequester)(nil).RequestReviews), varargs...)
return &MockGiteaReviewFetcherAndRequesterRequestReviewsCall{Call: call}
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestReviews", reflect.TypeOf((*MockGiteaReviewFetcherAndRequesterAndUnrequester)(nil).RequestReviews), varargs...)
return &MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall{Call: call}
}
// MockGiteaReviewFetcherAndRequesterRequestReviewsCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterRequestReviewsCall struct {
// MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaReviewFetcherAndRequesterRequestReviewsCall) Return(arg0 []*models.PullReview, arg1 error) *MockGiteaReviewFetcherAndRequesterRequestReviewsCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall) Return(arg0 []*models.PullReview, arg1 error) *MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaReviewFetcherAndRequesterRequestReviewsCall) Do(f func(*models.PullRequest, ...string) ([]*models.PullReview, error)) *MockGiteaReviewFetcherAndRequesterRequestReviewsCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall) Do(f func(*models.PullRequest, ...string) ([]*models.PullReview, error)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaReviewFetcherAndRequesterRequestReviewsCall) DoAndReturn(f func(*models.PullRequest, ...string) ([]*models.PullReview, error)) *MockGiteaReviewFetcherAndRequesterRequestReviewsCall {
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall) DoAndReturn(f func(*models.PullRequest, ...string) ([]*models.PullReview, error)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// UnrequestReview mocks base method.
func (m *MockGiteaReviewFetcherAndRequesterAndUnrequester) UnrequestReview(org, repo string, id int64, reviwers ...string) error {
m.ctrl.T.Helper()
varargs := []any{org, repo, id}
for _, a := range reviwers {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "UnrequestReview", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// UnrequestReview indicates an expected call of UnrequestReview.
func (mr *MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder) UnrequestReview(org, repo, id any, reviwers ...any) *MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall {
mr.mock.ctrl.T.Helper()
varargs := append([]any{org, repo, id}, reviwers...)
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnrequestReview", reflect.TypeOf((*MockGiteaReviewFetcherAndRequesterAndUnrequester)(nil).UnrequestReview), varargs...)
return &MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall{Call: call}
}
// MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall) Return(arg0 error) *MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall) Do(f func(string, string, int64, ...string) error) *MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall) DoAndReturn(f func(string, string, int64, ...string) error) *MockGiteaReviewFetcherAndRequesterAndUnrequesterUnrequestReviewCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGiteaUnreviewTimelineFetcher is a mock of GiteaUnreviewTimelineFetcher interface.
type MockGiteaUnreviewTimelineFetcher struct {
ctrl *gomock.Controller
recorder *MockGiteaUnreviewTimelineFetcherMockRecorder
isgomock struct{}
}
// MockGiteaUnreviewTimelineFetcherMockRecorder is the mock recorder for MockGiteaUnreviewTimelineFetcher.
type MockGiteaUnreviewTimelineFetcherMockRecorder struct {
mock *MockGiteaUnreviewTimelineFetcher
}
// NewMockGiteaUnreviewTimelineFetcher creates a new mock instance.
func NewMockGiteaUnreviewTimelineFetcher(ctrl *gomock.Controller) *MockGiteaUnreviewTimelineFetcher {
mock := &MockGiteaUnreviewTimelineFetcher{ctrl: ctrl}
mock.recorder = &MockGiteaUnreviewTimelineFetcherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGiteaUnreviewTimelineFetcher) EXPECT() *MockGiteaUnreviewTimelineFetcherMockRecorder {
return m.recorder
}
// GetTimeline mocks base method.
func (m *MockGiteaUnreviewTimelineFetcher) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTimeline", org, repo, idx)
ret0, _ := ret[0].([]*models.TimelineComment)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTimeline indicates an expected call of GetTimeline.
func (mr *MockGiteaUnreviewTimelineFetcherMockRecorder) GetTimeline(org, repo, idx any) *MockGiteaUnreviewTimelineFetcherGetTimelineCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTimeline", reflect.TypeOf((*MockGiteaUnreviewTimelineFetcher)(nil).GetTimeline), org, repo, idx)
return &MockGiteaUnreviewTimelineFetcherGetTimelineCall{Call: call}
}
// MockGiteaUnreviewTimelineFetcherGetTimelineCall wrap *gomock.Call
type MockGiteaUnreviewTimelineFetcherGetTimelineCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaUnreviewTimelineFetcherGetTimelineCall) Return(arg0 []*models.TimelineComment, arg1 error) *MockGiteaUnreviewTimelineFetcherGetTimelineCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaUnreviewTimelineFetcherGetTimelineCall) Do(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaUnreviewTimelineFetcherGetTimelineCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaUnreviewTimelineFetcherGetTimelineCall) DoAndReturn(f func(string, string, int64) ([]*models.TimelineComment, error)) *MockGiteaUnreviewTimelineFetcherGetTimelineCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// UnrequestReview mocks base method.
func (m *MockGiteaUnreviewTimelineFetcher) UnrequestReview(org, repo string, id int64, reviwers ...string) error {
m.ctrl.T.Helper()
varargs := []any{org, repo, id}
for _, a := range reviwers {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "UnrequestReview", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// UnrequestReview indicates an expected call of UnrequestReview.
func (mr *MockGiteaUnreviewTimelineFetcherMockRecorder) UnrequestReview(org, repo, id any, reviwers ...any) *MockGiteaUnreviewTimelineFetcherUnrequestReviewCall {
mr.mock.ctrl.T.Helper()
varargs := append([]any{org, repo, id}, reviwers...)
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnrequestReview", reflect.TypeOf((*MockGiteaUnreviewTimelineFetcher)(nil).UnrequestReview), varargs...)
return &MockGiteaUnreviewTimelineFetcherUnrequestReviewCall{Call: call}
}
// MockGiteaUnreviewTimelineFetcherUnrequestReviewCall wrap *gomock.Call
type MockGiteaUnreviewTimelineFetcherUnrequestReviewCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaUnreviewTimelineFetcherUnrequestReviewCall) Return(arg0 error) *MockGiteaUnreviewTimelineFetcherUnrequestReviewCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaUnreviewTimelineFetcherUnrequestReviewCall) Do(f func(string, string, int64, ...string) error) *MockGiteaUnreviewTimelineFetcherUnrequestReviewCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaUnreviewTimelineFetcherUnrequestReviewCall) DoAndReturn(f func(string, string, int64, ...string) error) *MockGiteaUnreviewTimelineFetcherUnrequestReviewCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
@@ -1850,12 +2164,13 @@ func (c *MockGiteaAddReviewCommentCall) DoAndReturn(f func(*models.PullRequest,
}
// CreatePullRequestIfNotExist mocks base method.
func (m *MockGitea) CreatePullRequestIfNotExist(repo *models.Repository, srcId, targetId, title, body string) (*models.PullRequest, error) {
func (m *MockGitea) CreatePullRequestIfNotExist(repo *models.Repository, srcId, targetId, title, body string) (*models.PullRequest, error, bool) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreatePullRequestIfNotExist", repo, srcId, targetId, title, body)
ret0, _ := ret[0].(*models.PullRequest)
ret1, _ := ret[1].(error)
return ret0, ret1
ret2, _ := ret[2].(bool)
return ret0, ret1, ret2
}
// CreatePullRequestIfNotExist indicates an expected call of CreatePullRequestIfNotExist.
@@ -1871,19 +2186,19 @@ type MockGiteaCreatePullRequestIfNotExistCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaCreatePullRequestIfNotExistCall) Return(arg0 *models.PullRequest, arg1 error) *MockGiteaCreatePullRequestIfNotExistCall {
c.Call = c.Call.Return(arg0, arg1)
func (c *MockGiteaCreatePullRequestIfNotExistCall) Return(arg0 *models.PullRequest, arg1 error, arg2 bool) *MockGiteaCreatePullRequestIfNotExistCall {
c.Call = c.Call.Return(arg0, arg1, arg2)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaCreatePullRequestIfNotExistCall) Do(f func(*models.Repository, string, string, string, string) (*models.PullRequest, error)) *MockGiteaCreatePullRequestIfNotExistCall {
func (c *MockGiteaCreatePullRequestIfNotExistCall) Do(f func(*models.Repository, string, string, string, string) (*models.PullRequest, error, bool)) *MockGiteaCreatePullRequestIfNotExistCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaCreatePullRequestIfNotExistCall) DoAndReturn(f func(*models.Repository, string, string, string, string) (*models.PullRequest, error)) *MockGiteaCreatePullRequestIfNotExistCall {
func (c *MockGiteaCreatePullRequestIfNotExistCall) DoAndReturn(f func(*models.Repository, string, string, string, string) (*models.PullRequest, error, bool)) *MockGiteaCreatePullRequestIfNotExistCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
@@ -2202,6 +2517,45 @@ func (c *MockGiteaGetIssueCommentsCall) DoAndReturn(f func(string, string, int64
return c
}
// GetLabels mocks base method.
func (m *MockGitea) GetLabels(org, repo string, idx int64) ([]*models.Label, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLabels", org, repo, idx)
ret0, _ := ret[0].([]*models.Label)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetLabels indicates an expected call of GetLabels.
func (mr *MockGiteaMockRecorder) GetLabels(org, repo, idx any) *MockGiteaGetLabelsCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLabels", reflect.TypeOf((*MockGitea)(nil).GetLabels), org, repo, idx)
return &MockGiteaGetLabelsCall{Call: call}
}
// MockGiteaGetLabelsCall wrap *gomock.Call
type MockGiteaGetLabelsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaGetLabelsCall) Return(arg0 []*models.Label, arg1 error) *MockGiteaGetLabelsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaGetLabelsCall) Do(f func(string, string, int64) ([]*models.Label, error)) *MockGiteaGetLabelsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaGetLabelsCall) DoAndReturn(f func(string, string, int64) ([]*models.Label, error)) *MockGiteaGetLabelsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// GetNotifications mocks base method.
func (m *MockGitea) GetNotifications(Type string, since *time.Time) ([]*models.NotificationThread, error) {
m.ctrl.T.Helper()
@@ -2793,6 +3147,45 @@ func (c *MockGiteaSetCommitStatusCall) DoAndReturn(f func(string, string, string
return c
}
// SetLabels mocks base method.
func (m *MockGitea) SetLabels(org, repo string, idx int64, labels []string) ([]*models.Label, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetLabels", org, repo, idx, labels)
ret0, _ := ret[0].([]*models.Label)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SetLabels indicates an expected call of SetLabels.
func (mr *MockGiteaMockRecorder) SetLabels(org, repo, idx, labels any) *MockGiteaSetLabelsCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLabels", reflect.TypeOf((*MockGitea)(nil).SetLabels), org, repo, idx, labels)
return &MockGiteaSetLabelsCall{Call: call}
}
// MockGiteaSetLabelsCall wrap *gomock.Call
type MockGiteaSetLabelsCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaSetLabelsCall) Return(arg0 []*models.Label, arg1 error) *MockGiteaSetLabelsCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaSetLabelsCall) Do(f func(string, string, int64, []string) ([]*models.Label, error)) *MockGiteaSetLabelsCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaSetLabelsCall) DoAndReturn(f func(string, string, int64, []string) ([]*models.Label, error)) *MockGiteaSetLabelsCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// SetNotificationRead mocks base method.
func (m *MockGitea) SetNotificationRead(notificationId int64) error {
m.ctrl.T.Helper()

View File

@@ -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 {
@@ -592,15 +600,16 @@ func PackageBuildStatusComp(A, B *PackageBuildStatus) int {
}
type BuildResult struct {
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"`
ScmSync string `xml:"scmsync"`
ScmInfo string `xml:"scminfo"`
Dirty bool `xml:"dirty,attr,omitempty"`
ScmSync string `xml:"scmsync,omitempty"`
ScmInfo string `xml:"scminfo,omitempty"`
Status []*PackageBuildStatus `xml:"status"`
Binaries []BinaryList `xml:"binarylist"`
Binaries []BinaryList `xml:"binarylist,omitempty"`
LastUpdate time.Time
}
@@ -627,8 +636,8 @@ type BinaryList struct {
}
type BuildResultList struct {
XMLName xml.Name `xml:"resultlist"`
State string `xml:"state,attr"`
XMLName xml.Name `xml:"resultlist"`
State string `xml:"state,attr"`
Result []*BuildResult `xml:"result"`
isLastBuild bool

View File

@@ -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,8 @@ type PRSet struct {
PRs []*PRInfo
Config *AutogitConfig
BotUser string
BotUser string
HasAutoStaging bool
}
func (prinfo *PRInfo) PRComponents() (org string, repo string, idx int64) {
@@ -32,6 +34,41 @@ func (prinfo *PRInfo) PRComponents() (org string, repo string, idx int64) {
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) {
for _, p := range currentSet {
if pr.Index == p.PR.Index && pr.Base.Repo.Name == p.PR.Base.Repo.Name && pr.Base.Repo.Owner.UserName == p.PR.Base.Repo.Owner.UserName {
@@ -62,13 +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) {
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
@@ -78,6 +117,29 @@ func LastPrjGitRefOnTimeline(gitea GiteaPRTimelineFetcher, org, repo string, num
issue.Repository.Owner == prjGitOrg &&
issue.Repository.Name == prjGitRepo {
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 {
@@ -94,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
@@ -104,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
}
@@ -120,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,
@@ -127,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 &&
@@ -212,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(nil))
}
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(nil), maintainers.ListPackageMaintainers(pkg, nil), 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)
}
}
}
@@ -298,11 +452,12 @@ func (rs *PRSet) IsApproved(gitea GiteaPRChecker, maintainers MaintainershipData
if err == nil && prjgit != nil {
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
@@ -318,11 +473,12 @@ func (rs *PRSet) IsApproved(gitea GiteaPRChecker, maintainers MaintainershipData
pkg := pr.PR.Base.Repo.Name
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)
@@ -344,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
@@ -355,21 +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 {
// 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 {
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
}

View File

@@ -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))
@@ -75,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",
@@ -147,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)
},
},
@@ -179,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)
},
},
@@ -207,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",
@@ -241,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",
@@ -275,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",
@@ -311,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",
@@ -346,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",
@@ -388,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",
@@ -430,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",
@@ -473,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",
@@ -500,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",
@@ -515,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)
@@ -619,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: "prg/repo#main",
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: "prg/repo#main",
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: "prg/repo#main",
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: "prg/repo#main",
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: "prg/repo#main",
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: "org/repo#main",
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: "prg/repo#main",
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(gomock.Any()).Return([]string{"prjmaintainer"}).AnyTimes()
maintainership_mock.EXPECT().ListPackageMaintainers("pkgrepo", gomock.Any()).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: "org/repo#main",
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(gomock.Any()).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()
@@ -977,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)
@@ -1005,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
@@ -1035,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)

View File

@@ -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
}

View File

@@ -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"},

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}
@@ -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
}

View File

@@ -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)
}
})
}
}

View 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";
}

View 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";
}

View File

@@ -274,6 +274,13 @@ func findMissingDevelBranch(git common.Git, pkg, project string) {
}
func importFactoryRepoAndCheckHistory(pkg string, meta *common.PackageMeta) (factoryRepo *models.Repository, retErr error) {
devel_project, err := devel_projects.GetDevelProject(pkg)
if err != nil {
return nil, fmt.Errorf("Error finding devel project for '%s'. Assuming independent: %w", pkg, err)
} else if devel_project != prj {
return nil, fmt.Errorf("Not factory devel project -- importing package '%s' as independent: %w", pkg, err)
}
if repo, err := client.Repository.RepoGet(repository.NewRepoGetParams().WithDefaults().WithOwner("pool").WithRepo(giteaPackage(pkg)), r.DefaultAuthentication); err != nil || repo.Payload.ObjectFormatName != "sha256" {
if err != nil && !errors.Is(err, &repository.RepoGetNotFound{}) {
log.Panicln(err)
@@ -323,13 +330,9 @@ func importFactoryRepoAndCheckHistory(pkg string, meta *common.PackageMeta) (fac
return
}
devel_project, err := devel_projects.GetDevelProject(pkg)
common.LogDebug("Devel project:", devel_project, err)
if err == common.DevelProjectNotFound {
// assume it's this project, maybe removed from factory
devel_project = prj
if err := gitImporter("openSUSE:Factory", pkg); err != nil {
common.PanicOnError(gitImporter(prj, pkg))
}
common.LogDebug("finding missing branches in", pkg, devel_project)
findMissingDevelBranch(git, pkg, devel_project)
return
}
@@ -502,9 +505,15 @@ func importDevelRepoAndCheckHistory(pkg string, meta *common.PackageMeta) *model
common.PanicOnError(os.RemoveAll(path.Join(git.GetPath(), pkg)))
}
if err := gitImporter("openSUSE:Factory", pkg); err != nil {
devel_project, _ := devel_projects.GetDevelProject(pkg)
if devel_project == prj {
if err := gitImporter("openSUSE:Factory", pkg); err != nil {
common.PanicOnError(gitImporter(prj, pkg))
}
} else {
common.PanicOnError(gitImporter(prj, pkg))
}
if p := strings.TrimSpace(git.GitExecWithOutputOrPanic(pkg, "rev-list", "--max-parents=0", "--count", "factory")); p != "1" {
common.LogError("Failed to import package:", pkg)
common.PanicOnError(fmt.Errorf("Expecting 1 root in after devel import, but have %s", p))

View File

@@ -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

View File

@@ -0,0 +1,4 @@
#!/usr/bin/bash
osc api '/search/package?match=scmsync' | ../xml_package_parse | ../find_factory_commit_in_gitpkg.pl

View 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 = '';
}
}

View File

@@ -14,15 +14,11 @@ import (
"src.opensuse.org/autogits/common"
)
type Status struct {
Context string `json:"context"`
State string `json:"state"`
TargetUrl string `json:"target_url"`
}
type StatusInput struct {
State string `json:"state"`
TargetUrl string `json:"target_url"`
Description string `json:"description"`
Context string `json:"context"`
State string `json:"state"`
TargetUrl string `json:"target_url"`
}
func main() {
@@ -59,23 +55,26 @@ func StatusProxy(w http.ResponseWriter, r *http.Request) {
config, ok := r.Context().Value(configKey).(*Config)
if !ok {
common.LogError("Config missing from context")
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], "Bearer") {
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
}
@@ -83,6 +82,7 @@ func StatusProxy(w http.ResponseWriter, r *http.Request) {
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
}
@@ -104,13 +104,8 @@ func StatusProxy(w http.ResponseWriter, r *http.Request) {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
status := Status{
Context: "Build in obs",
State: statusinput.State,
TargetUrl: statusinput.TargetUrl,
}
status_payload, err := json.Marshal(status)
status_payload, err := json.Marshal(statusinput)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
@@ -131,8 +126,8 @@ func StatusProxy(w http.ResponseWriter, r *http.Request) {
return
}
req.Header.Add("Content-Type", "Content-Type")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", ForgeToken))
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("token %s", ForgeToken))
resp, err := client.Do(req)

View 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

View File

@@ -1,41 +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.
Configiuration
Configuration
--------------
Groups are defined in the workflow.config inside the project git. They take following options,
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
},
],
...
...
"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

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/url"
"os"
"regexp"
"runtime/debug"
"slices"
@@ -17,20 +18,23 @@ import (
"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(newGroupName string) {
groupName = newGroupName
acceptRx = regexp.MustCompile("^:\\s*(LGTM|approved?)")
rejectRx = regexp.MustCompile("^:\\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
@@ -50,20 +54,20 @@ func ParseReviewLine(reviewText string) (bool, string) {
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)
}
}
}
@@ -113,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
}
}
@@ -125,9 +129,9 @@ func FindAcceptableReviewInTimeline(user string, timeline []*models.TimelineComm
return nil
}
func FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.TimelineComment {
func (bot *ReviewBot) FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.TimelineComment {
for _, t := range timeline {
if t.Type == common.TimelineCommentType_Review && t.User.UserName == groupName && t.Created == t.Updated {
if t.Type == common.TimelineCommentType_Review && t.User.UserName == bot.groupName && t.Created == t.Updated {
return t
}
}
@@ -135,13 +139,13 @@ func FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.Tim
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) 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")
@@ -149,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 {
@@ -168,14 +172,14 @@ func ProcessNotifications(notification *models.NotificationThread, gitea common.
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)
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 := ProcessPR(pr); err == nil && !common.IsDryRun {
if err := gitea.SetNotificationRead(notification.ID); err != nil {
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 {
@@ -185,24 +189,24 @@ func ProcessNotifications(notification *models.NotificationThread, gitea common.
var ReviewNotFinished = fmt.Errorf("Review is not finished")
func ProcessPR(pr *models.PullRequest) error {
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)
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 {
return fmt.Errorf("Cannot find config for: %s", pr.URL)
}
@@ -212,51 +216,51 @@ func ProcessPR(pr *models.PullRequest) error {
return nil
}
reviews, err := gitea.GetPullRequestReviews(org, repo, id)
reviews, err := bot.gitea.GetPullRequestReviews(org, repo, id)
if err != nil {
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 {
return fmt.Errorf("Failed to fetch timeline to review. %w", err)
}
groupConfig, err := config.GetReviewGroup(groupName)
groupConfig, err := config.GetReviewGroup(bot.groupName)
if err != nil {
return fmt.Errorf("Failed to fetch review group. %w", err)
}
// submitter cannot be reviewer
requestReviewers := groupConfig.Reviewers
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 {
text := reviewer + " approved a review on behalf of " + groupName
if review := FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
_, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, text)
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)
}
UnrequestReviews(gitea, org, repo, id, requestReviewers)
bot.UnrequestReviews(org, repo, id, requestReviewers)
}
}
common.LogInfo(" -> approved by", reviewer)
common.LogInfo(" review at", review.Created)
return nil
} else if ReviewRejected(review.Body) {
} else if bot.ReviewRejected(review.Body) {
if !common.IsDryRun {
text := reviewer + " requested changes on behalf of " + groupName + ". See " + review.HTMLURL
if review := FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Changes requested. See review by: "+reviewer)
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)
}
UnrequestReviews(gitea, org, repo, id, requestReviewers)
bot.UnrequestReviews(org, repo, id, requestReviewers)
}
}
common.LogInfo(" -> declined by", reviewer)
@@ -270,7 +274,7 @@ func ProcessPR(pr *models.PullRequest) error {
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 {
@@ -283,42 +287,40 @@ func ProcessPR(pr *models.PullRequest) error {
// 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, ", "), ".\n\n"+
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: `@"+groupName+": approve`.\n"+
"To request changes on behalf of the group, create the following comment: `@"+groupName+": decline` followed with lines justifying the decision.\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"
}
gitea.AddComment(pr, helpComment)
bot.gitea.AddComment(pr, helpComment)
}
return ReviewNotFinished
}
func PeriodReviewCheck() {
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)
}
}
var gitea common.Gitea
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")
@@ -328,6 +330,24 @@ func main() {
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:")
@@ -336,7 +356,7 @@ func main() {
flag.Usage()
return
}
groupName = args[0]
targetGroupName := args[0]
if *configFile == "" {
common.LogError("Missing config file")
@@ -359,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
}
@@ -400,10 +419,13 @@ func main() {
}
config_update := ConfigUpdatePush{
bot: bot,
config_modified: make(chan *common.AutogitConfig),
}
process_issue_pr := IssueCommentProcessor{}
process_issue_pr := IssueCommentProcessor{
bot: bot,
}
configUpdates := &common.RabbitMQGiteaEventsProcessor{
Orgs: []string{},
@@ -413,7 +435,7 @@ func main() {
},
}
configUpdates.Connection().RabbitURL = u
for _, c := range configs {
for _, c := range bot.configs {
if org, _, _ := c.GetPrjGit(); !slices.Contains(configUpdates.Orgs, org) {
configUpdates.Orgs = append(configUpdates.Orgs, org)
}
@@ -426,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
}
}
}
@@ -446,7 +468,7 @@ func main() {
}
}
PeriodReviewCheck()
bot.PeriodReviewCheck()
time.Sleep(time.Duration(*interval * int64(time.Minute)))
}
}

View File

@@ -1,6 +1,359 @@
package main
import "testing"
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 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 {
@@ -60,16 +413,78 @@ func TestReviewApprovalCheck(t *testing.T) {
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) {
InitRegex(test.GroupName)
bot := &ReviewBot{}
bot.InitRegex(test.GroupName)
if r := ReviewAccepted(test.InString); r != test.Approved {
if r := bot.ReviewAccepted(test.InString); r != test.Approved {
t.Error("ReviewAccepted() returned", r, "expecting", test.Approved)
}
if r := ReviewRejected(test.InString); r != test.Rejected {
if r := bot.ReviewRejected(test.InString); r != test.Rejected {
t.Error("ReviewRejected() returned", r, "expecting", test.Rejected)
}
})

View File

@@ -7,7 +7,9 @@ import (
"src.opensuse.org/autogits/common"
)
type IssueCommentProcessor struct{}
type IssueCommentProcessor struct {
bot *ReviewBot
}
func (s *IssueCommentProcessor) ProcessFunc(req *common.Request) error {
if req.Type != common.RequestType_IssueComment {
@@ -19,14 +21,15 @@ func (s *IssueCommentProcessor) ProcessFunc(req *common.Request) error {
repo := data.Repository.Name
index := int64(data.Issue.Number)
pr, err := gitea.GetPullRequest(org, repo, index)
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 ProcessPR(pr)
return s.bot.ProcessPR(pr)
}
type ConfigUpdatePush struct {
bot *ReviewBot
config_modified chan *common.AutogitConfig
}
@@ -46,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
}
@@ -64,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
View 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)
}
})
}

View File

@@ -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.

View File

@@ -109,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??")
@@ -346,12 +351,9 @@ func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullReque
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 {
@@ -380,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 {
@@ -389,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
@@ -903,7 +941,8 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
CreateQASubProject(stagingConfig, git, gitea, pr,
stagingProject,
setup.Origin,
setup.Name)
setup.Name,
setup.BuildDisableRepos)
msg = msg + ObsWebHost + "/project/show/" +
stagingProject + ":" + setup.Name + "\n"
}
@@ -1047,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)

View File

@@ -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

Binary file not shown.

Binary file not shown.

View File

@@ -19,13 +19,16 @@ package main
*/
import (
"bytes"
"encoding/json"
"encoding/xml"
"flag"
"fmt"
"html"
"io"
"log"
"maps"
"net/http"
"net/url"
"os"
"slices"
"strings"
@@ -40,52 +43,83 @@ const (
var obs *common.ObsClient
type RepoBuildCounters struct {
Repository, Arch string
Status string
BuildStatusCounter map[string]int
}
func ProjectStatusSummarySvg(res []*common.BuildResult) []byte {
if len(res) == 0 {
return nil
}
list := common.BuildResultList{
Result: res,
}
pkgs := list.GetPackageList()
package_names := list.GetPackageList()
maxLen := 0
for _, p := range pkgs {
for _, p := range package_names {
maxLen = max(maxLen, len(p))
}
width := float32(len(list.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="8em" height="1.5em" fill="#800" />
</g>
<g id="s"> <!--succeeded-->
<rect width="8em" height="1.5em" fill="#080" />
</g>
<g id="buidling"> <!--building-->
<rect width="8em" height="1.5em" fill="#880" />
</g>
</defs>`)
status := make([]RepoBuildCounters, len(res))
ret.WriteString(`<use href="#f" x="1em" y="2em"/>`)
ret.WriteString(`</svg>`)
return ret.Bytes()
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 LinkToBuildlog(R *common.BuildResult, S *common.PackageBuildStatus) string {
if R != nil && S != nil {
switch S.Code {
case "succeeded", "failed", "building":
return "/buildlog/" + R.Project + "/" + S.Package + "/" + R.Repository + "/" + R.Arch
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{}
@@ -105,7 +139,7 @@ func PackageStatusSummarySvg(pkg string, res []*common.BuildResult) []byte {
}
}
ret := NewSvg()
ret := NewSvg(SvgType_Package)
for _, pkg = range package_names {
// if len(package_names) > 1 {
ret.WriteTitle(pkg)
@@ -160,31 +194,61 @@ func BuildStatusSvg(repo *common.BuildResult, status *common.PackageBuildStatus)
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 + `">` + buildStatus.Code + `</text>` + endTag + `</svg>`)
`<text x="4em" y="1.1em" text-anchor="middle" fill="` + textColor + `">` + html.EscapeString(buildStatus.Code) + `</text>` + endTag + `</svg>`)
}
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)
}
}
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() {
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")
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", "https://api.opensuse.org", "OBS API endpoint for package buildlog information")
ObsUrl = flag.String("obs-url", obsUrlDef, "OBS API endpoint for package buildlog information")
debug := flag.Bool("debug", false, "Enable debug logging")
// RabbitMQHost := flag.String("rabbit-mq", "amqps://rabbit.opensuse.org", "RabbitMQ message bus server")
// Topic := flag.String("topic", "opensuse.obs", "RabbitMQ topic prefix")
flag.Parse()
if *debug {
common.SetLoggingLevel(common.LogLevelDebug)
}
// common.PanicOnError(common.RequireObsSecretToken())
var err error
if obs, err = common.NewObsClient(*obsUrl); err != nil {
log.Fatal(err)
}
if redisUrl := os.Getenv("REDIS"); len(redisUrl) > 0 {
RedisConnect(redisUrl)
} else {
@@ -196,7 +260,7 @@ func main() {
go func() {
for {
if rescanRepoError = RescanRepositories(); rescanRepoError != nil {
common.LogError("Failed to rescan repositories.", err)
common.LogError("Failed to rescan repositories.", rescanRepoError)
}
time.Sleep(time.Minute * 5)
}
@@ -211,66 +275,117 @@ func main() {
res.Write([]byte("404 page not found\n"))
})
http.HandleFunc("GET /status/{Project}", func(res http.ResponseWriter, req *http.Request) {
mime := ParseMimeHeader(req)
obsPrj := req.PathValue("Project")
common.LogInfo(" request: GET /status/" + obsPrj)
res.WriteHeader(http.StatusBadRequest)
})
http.HandleFunc("GET /status/{Project}/{Package}", func(res http.ResponseWriter, req *http.Request) {
obsPrj := req.PathValue("Project")
obsPkg := req.PathValue("Package")
common.LogInfo(" request: GET /status/" + obsPrj + "/" + obsPkg)
common.LogInfo(" GET /status/"+obsPrj, "["+mime.MimeType()+"]")
status := FindAndUpdateProjectResults(obsPrj)
if len(status) == 0 {
res.WriteHeader(404)
return
}
svg := PackageStatusSummarySvg(obsPkg, status)
res.Header().Add("content-type", "image/svg+xml")
res.Header().Add("size", fmt.Sprint(len(svg)))
res.Write(svg)
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}/{Repository}", func(res http.ResponseWriter, req *http.Request) {
http.HandleFunc("GET /status/{Project}/{Package}", func(res http.ResponseWriter, req *http.Request) {
mime := ParseMimeHeader(req)
obsPrj := req.PathValue("Project")
obsPkg := req.PathValue("Package")
repo := req.PathValue("Repository")
common.LogInfo(" request: GET /status/" + obsPrj + "/" + obsPkg)
common.LogInfo(" GET /status/"+obsPrj+"/"+obsPkg, "["+mime.MimeType()+"]")
status := FindAndUpdateRepoResults(obsPrj, repo)
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
}
svg := PackageStatusSummarySvg(obsPkg, status)
res.Header().Add("content-type", "image/svg+xml")
res.Header().Add("size", fmt.Sprint(len(svg)))
res.Write(svg)
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)
common.LogInfo(" GET /status/"+prj+"/"+pkg+"/"+repo+"/"+arch, "["+mime.MimeType()+"]")
res.Header().Add("content-type", "image/svg+xml")
for _, r := range FindAndUpdateProjectResults(prj) {
if r.Arch == arch && r.Repository == repo {
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 {
res.Write(BuildStatusSvg(r, r.Status[idx]))
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
}
}
res.Write(BuildStatusSvg(nil, &common.PackageBuildStatus{Package: pkg, Code: "unknown"}))
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 /serach?" + req.URL.RawQuery)
common.LogInfo("GET /search?" + req.URL.RawQuery)
queries := req.URL.Query()
if !queries.Has("q") {
res.WriteHeader(400)
@@ -302,7 +417,7 @@ func main() {
repo := req.PathValue("Repository")
arch := req.PathValue("Arch")
res.Header().Add("location", "https://build.opensuse.org/package/live_build_log/"+prj+"/"+pkg+"/"+repo+"/"+arch)
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

View File

@@ -1,6 +1,9 @@
package main
import (
"compress/bzip2"
"encoding/json"
"io"
"os"
"testing"
@@ -8,11 +11,13 @@ import (
)
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",
@@ -80,3 +85,36 @@ func TestStatusSvg(t *testing.T) {
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
})
}
}

View 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
}

View File

@@ -90,6 +90,10 @@ 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 {
@@ -103,6 +107,10 @@ 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 {
@@ -116,6 +124,10 @@ 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 {

View File

@@ -3,7 +3,10 @@ package main
import (
"bytes"
"fmt"
"html"
"net/url"
"slices"
"strings"
)
type SvgWriter struct {
@@ -12,65 +15,78 @@ type SvgWriter struct {
out bytes.Buffer
}
func NewSvg() *SvgWriter {
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">`)
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>`)
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">` + title + "</text>")
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">` + subtitle + `</text>`)
svg.out.WriteString(`<text stroke="black" fill="black" x="3ex" y="` + fmt.Sprint(svg.ypos-.6) + `em">` + html.EscapeString(subtitle) + `</text>`)
svg.ypos += 2
}
@@ -97,23 +113,38 @@ func (svg *SvgWriter) WritePackageStatus(loglink, arch, status, detail string) {
return "un"
}
svg.out.WriteString(`<text fill="#113" x="5ex" y="` + fmt.Sprint(svg.ypos-.6) + `em">` + arch + `</text>`)
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 {
svg.out.WriteString(`<a href="` + loglink + `" target="_blank" rel="noopener">`)
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>` + fmt.Sprint(detail) + "</title>")
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&amp;" + url.QueryEscape(status) + "=1&amp;arch_" + url.QueryEscape(arch) + "=1&amp;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
View 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

View 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

View 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

View 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

View File

@@ -1,33 +1,51 @@
Direct Workflow bot
===================
The project submodule is automatically updated by the direct bot whenever a branch is updated in a package repository.
This bot can coexist with the Workflow PR bot, which is instead triggered by a new package PR.
Target Usage
------------
Devel project, where direct pushes to package git are possible.
Areas of responsibility
-----------------------
1. Keep ProjectGit in sync with packages in the organization
* on pushes to package, updates the submodule commit id
to the default branch HEAD (as configured in Gitea)
* on repository adds, creates a new submodule (if non empty)
* on repository removal, removes the submodule
* **On pushes to package**: updates the submodule commit ID to the default branch HEAD (as configured in Gitea).
* **On repository adds**: creates a new submodule (if non-empty).
* **On repository removal**: removes the submodule.
**Note:** If you want to revert a change in a package, you need to do that manually in the project git.
NOTE: reverts (push HEAD^) are not supported as they would step-on the
work of the workflow-pr bot. Manual update of the project git is
required in this case.
Configuration
-------------
Uses `workflow.config` for configuration. Parameters
Uses `workflow.config` for configuration.
* _Workflows_: ["direct"] -- direct entry enables direct workflow. **Mandatory**
* _Organization_: organization that holds all the packages. **Mandatory**
* _Branch_: branch updated in repo's, or blank for default package branch
* _GitProjectName_: package in above org, or `org/package#branch` for PrjGit. By default assumes `_ObsPrj` with default branch and in the `Organization`
| Field name | Details | Mandatory | Type | Allowed Values | Default |
| ----- | ----- | ----- | ----- | ----- | ----- |
| *Workflows* | Type of workflow | yes | string | “direct” | |
| *Organization* | The organization that holds all the packages | yes | string | | |
| *Branch* | The designated branch for packages | no | string | | blank (default package branch) |
| *GitProjectName* | Repository and branch where the ProjectGit lives. | no | string | **Format**: `org/project_repo#branch` | By default assumes `_ObsPrj` with default branch in the *Organization* |
NOTE: `-rm`, `-removed`, `-deleted` are all removed suffixes used to indicate current branch is a placeholder for previously existing package. These branches will be ignored by the bot, and if default, the package will be removed and will not be added to the project.
Target Usage
------------
Devel project, where direct pushes to package git are possible
Environment Variables
-------
* `GITEA_TOKEN` (required)
* `AMQP_USERNAME` (required)
* `AMQP_PASSWORD` (required)
* `AUTOGITS_CONFIG` (required)
* `AUTOGITS_URL` - default: https://src.opensuse.org
* `AUTOGITS_RABBITURL` - default: amqps://rabbit.opensuse.org
* `AUTOGITS_DEBUG` - disabled by default, set to any value to enable
* `AUTOGITS_CHECK_ON_START` - disabled by default, set to any value to enable
* `AUTOGITS_REPO_PATH` - default is temporary directory
* `AUTOGITS_IDENTITY_FILE` - in case where we need explicit identify path for ssh specified

View File

@@ -22,7 +22,6 @@ import (
"flag"
"fmt"
"io/fs"
"log"
"math/rand"
"net/url"
"os"
@@ -40,7 +39,7 @@ import (
const (
AppName = "direct_workflow"
GitAuthor = "AutoGits prjgit-updater"
GitEmail = "adam+autogits-direct@zombino.com"
GitEmail = "autogits-direct@noreply@src.opensuse.org"
)
var configuredRepos map[string][]*common.AutogitConfig
@@ -53,18 +52,6 @@ func isConfiguredOrg(org *common.Organization) bool {
return found
}
func concatenateErrors(err1, err2 error) error {
if err1 == nil {
return err2
}
if err2 == nil {
return err1
}
return fmt.Errorf("%w\n%w", err1, err2)
}
type RepositoryActionProcessor struct{}
func (*RepositoryActionProcessor) ProcessFunc(request *common.Request) error {
@@ -72,69 +59,90 @@ func (*RepositoryActionProcessor) ProcessFunc(request *common.Request) error {
configs, configFound := configuredRepos[action.Organization.Username]
if !configFound {
log.Printf("Repository event for %s. Not configured. Ignoring.\n", action.Organization.Username)
common.LogInfo("Repository event for", action.Organization.Username, ". Not configured. Ignoring.", action.Organization.Username)
return nil
}
for _, config := range configs {
if org, repo, _ := config.GetPrjGit(); org == action.Repository.Owner.Username && repo == action.Repository.Name {
log.Println("+ ignoring repo event for PrjGit repository", config.GitProjectName)
common.LogError("+ ignoring repo event for PrjGit repository", config.GitProjectName)
return nil
}
}
var err error
for _, config := range configs {
err = concatenateErrors(err, processConfiguredRepositoryAction(action, config))
processConfiguredRepositoryAction(action, config)
}
return err
return nil
}
func processConfiguredRepositoryAction(action *common.RepositoryWebhookEvent, config *common.AutogitConfig) error {
func processConfiguredRepositoryAction(action *common.RepositoryWebhookEvent, config *common.AutogitConfig) {
gitOrg, gitPrj, gitBranch := config.GetPrjGit()
git, err := gh.CreateGitHandler(config.Organization)
common.PanicOnError(err)
defer git.Close()
if len(config.Branch) == 0 {
config.Branch = action.Repository.Default_Branch
configBranch := config.Branch
if len(configBranch) == 0 {
configBranch = action.Repository.Default_Branch
if common.IsRemovedBranch(configBranch) {
common.LogDebug(" - default branch has deleted suffix. Skipping")
return
}
if len(configBranch) == 0 {
common.LogDebug("Empty default branch in message. Maybe race-condition?")
repo, err := gitea.GetRepository(action.Repository.Owner.Username, action.Repository.Name)
if err != nil {
common.LogError("Failed to fetch repository we have an event for?", action.Repository.Owner.Username, action.Repository.Name)
return
}
if len(repo.DefaultBranch) == 0 {
common.LogError("Default branch is somehow empty. We cannot do anything.")
return
}
configBranch = repo.DefaultBranch
}
}
prjGitRepo, err := gitea.CreateRepositoryIfNotExist(git, gitOrg, gitPrj)
if err != nil {
return fmt.Errorf("Error accessing/creating prjgit: %s/%s#%s err: %w", gitOrg, gitPrj, gitBranch, err)
common.LogError("Error accessing/creating prjgit:", gitOrg, gitPrj, gitBranch, err)
return
}
remoteName, err := git.GitClone(gitPrj, gitBranch, prjGitRepo.SSHURL)
common.PanicOnError(err)
git.GitExecQuietOrPanic(gitPrj, "submodule", "deinit", "--all", "-f")
switch action.Action {
case "created":
if action.Repository.Object_Format_Name != "sha256" {
return fmt.Errorf(" - '%s' repo is not sha256. Ignoring.", action.Repository.Name)
common.LogError(" - ", action.Repository.Name, "repo is not sha256. Ignoring.")
return
}
common.PanicOnError(git.GitExec(gitPrj, "submodule", "--quiet", "add", "--depth", "1", action.Repository.Clone_Url, action.Repository.Name))
defer git.GitExecOrPanic(gitPrj, "submodule", "deinit", "--all")
common.PanicOnError(git.GitExec(gitPrj, "submodule", "--quiet", "add", "--force", "--depth", "1", action.Repository.Clone_Url, action.Repository.Name))
defer git.GitExecQuietOrPanic(gitPrj, "submodule", "deinit", "--all", "-f")
branch := strings.TrimSpace(git.GitExecWithOutputOrPanic(path.Join(gitPrj, action.Repository.Name), "branch", "--show-current"))
if branch != config.Branch {
if err := git.GitExec(path.Join(gitPrj, action.Repository.Name), "fetch", "--depth", "1", "origin", config.Branch+":"+config.Branch); err != nil {
return fmt.Errorf("error fetching branch %s. ignoring as non-existent. err: %w", config.Branch, err) // no branch? so ignore repo here
if branch != configBranch {
if err := git.GitExec(path.Join(gitPrj, action.Repository.Name), "fetch", "--depth", "1", "origin", configBranch+":"+configBranch); err != nil {
common.LogError("error fetching branch", configBranch, ". ignoring as non-existent.", err) // no branch? so ignore repo here
return
}
common.PanicOnError(git.GitExec(path.Join(gitPrj, action.Repository.Name), "checkout", config.Branch))
common.PanicOnError(git.GitExec(path.Join(gitPrj, action.Repository.Name), "checkout", configBranch))
}
common.PanicOnError(git.GitExec(gitPrj, "commit", "-m", "Automatic package inclusion via Direct Workflow"))
common.PanicOnError(git.GitExec(gitPrj, "commit", "-m", "Auto-inclusion "+action.Repository.Name))
if !noop {
common.PanicOnError(git.GitExec(gitPrj, "push"))
}
case "deleted":
if stat, err := os.Stat(filepath.Join(git.GetPath(), gitPrj, action.Repository.Name)); err != nil || !stat.IsDir() {
if DebugMode {
log.Println("delete event for", action.Repository.Name, "-- not in project. Ignoring")
}
return nil
common.LogDebug("delete event for", action.Repository.Name, "-- not in project. Ignoring")
return
}
common.PanicOnError(git.GitExec(gitPrj, "rm", action.Repository.Name))
common.PanicOnError(git.GitExec(gitPrj, "commit", "-m", "Automatic package removal via Direct Workflow"))
@@ -143,10 +151,9 @@ func processConfiguredRepositoryAction(action *common.RepositoryWebhookEvent, co
}
default:
return fmt.Errorf("%s: %s", "Unknown action type", action.Action)
common.LogError("Unknown action type:", action.Action)
return
}
return nil
}
type PushActionProcessor struct{}
@@ -156,77 +163,83 @@ func (*PushActionProcessor) ProcessFunc(request *common.Request) error {
configs, configFound := configuredRepos[action.Repository.Owner.Username]
if !configFound {
log.Printf("Repository event for %s. Not configured. Ignoring.\n", action.Repository.Owner.Username)
common.LogDebug("Repository event for", action.Repository.Owner.Username, ". Not configured. Ignoring.")
return nil
}
for _, config := range configs {
if gitOrg, gitPrj, _ := config.GetPrjGit(); gitOrg == action.Repository.Owner.Username && gitPrj == action.Repository.Name {
log.Println("+ ignoring push to PrjGit repository", config.GitProjectName)
common.LogInfo("+ ignoring push to PrjGit repository", config.GitProjectName)
return nil
}
}
var err error
for _, config := range configs {
err = concatenateErrors(err, processConfiguredPushAction(action, config))
processConfiguredPushAction(action, config)
}
return err
return nil
}
func processConfiguredPushAction(action *common.PushWebhookEvent, config *common.AutogitConfig) error {
func processConfiguredPushAction(action *common.PushWebhookEvent, config *common.AutogitConfig) {
gitOrg, gitPrj, gitBranch := config.GetPrjGit()
git, err := gh.CreateGitHandler(config.Organization)
common.PanicOnError(err)
defer git.Close()
log.Printf("push to: %s/%s for %s/%s#%s", action.Repository.Owner.Username, action.Repository.Name, gitOrg, gitPrj, gitBranch)
if len(config.Branch) == 0 {
config.Branch = action.Repository.Default_Branch
log.Println(" + default branch", action.Repository.Default_Branch)
common.LogDebug("push to:", action.Repository.Owner.Username, action.Repository.Name, "for:", gitOrg, gitPrj, gitBranch)
branch := config.Branch
if len(branch) == 0 {
if common.IsRemovedBranch(branch) {
common.LogDebug(" + default branch has removed suffix:", branch, "Skipping.")
return
}
branch = action.Repository.Default_Branch
common.LogDebug(" + using default branch", branch)
}
prjGitRepo, err := gitea.CreateRepositoryIfNotExist(git, gitOrg, gitPrj)
if err != nil {
return fmt.Errorf("Error accessing/creating prjgit: %s/%s err: %w", gitOrg, gitPrj, err)
common.LogError("Error accessing/creating prjgit:", gitOrg, gitPrj, err)
return
}
remoteName, err := git.GitClone(gitPrj, gitBranch, prjGitRepo.SSHURL)
common.PanicOnError(err)
git.GitExecQuietOrPanic(gitPrj, "submodule", "deinit", "--all", "-f")
headCommitId, err := git.GitRemoteHead(gitPrj, remoteName, gitBranch)
common.PanicOnError(err)
commit, ok := git.GitSubmoduleCommitId(gitPrj, action.Repository.Name, headCommitId)
for ok && action.Head_Commit.Id == commit {
log.Println(" -- nothing to do, commit already in ProjectGit")
return nil
common.LogDebug(" -- nothing to do, commit already in ProjectGit")
return
}
if stat, err := os.Stat(filepath.Join(git.GetPath(), gitPrj, action.Repository.Name)); err != nil || !stat.IsDir() {
if DebugMode {
log.Println("Pushed to package that is not part of the project. Ignoring:", err)
}
return nil
if stat, err := os.Stat(filepath.Join(git.GetPath(), gitPrj, action.Repository.Name)); err != nil {
git.GitExecOrPanic(gitPrj, "submodule", "--quiet", "add", "--force", "--depth", "1", action.Repository.Clone_Url, action.Repository.Name)
common.LogDebug("Pushed to package that is not part of the project. Re-adding...", err)
} else if !stat.IsDir() {
common.LogError("Pushed to a package that is not a submodule but exists in the project. Ignoring.")
return
}
git.GitExecOrPanic(gitPrj, "submodule", "update", "--init", "--depth", "1", "--checkout", action.Repository.Name)
defer git.GitExecOrPanic(gitPrj, "submodule", "deinit", "--all")
git.GitExecOrPanic(gitPrj, "submodule", "update", "--init", "--force", "--depth", "1", "--checkout", action.Repository.Name)
defer git.GitExecQuietOrPanic(gitPrj, "submodule", "deinit", "--all", "-f")
if err := git.GitExec(filepath.Join(gitPrj, action.Repository.Name), "fetch", "--depth", "1", "--force", remoteName, config.Branch+":"+config.Branch); err != nil {
return fmt.Errorf("error fetching branch %s. ignoring as non-existent. err: %w", config.Branch, err) // no branch? so ignore repo here
if err := git.GitExec(filepath.Join(gitPrj, action.Repository.Name), "fetch", "--depth", "1", "--force", "origin", branch+":"+branch); err != nil {
common.LogError("Error fetching branch:", branch, "Ignoring as non-existent.", err)
return
}
id, err := git.GitRemoteHead(filepath.Join(gitPrj, action.Repository.Name), remoteName, config.Branch)
id, err := git.GitBranchHead(filepath.Join(gitPrj, action.Repository.Name), branch)
common.PanicOnError(err)
if action.Head_Commit.Id == id {
git.GitExecOrPanic(filepath.Join(gitPrj, action.Repository.Name), "checkout", id)
git.GitExecOrPanic(gitPrj, "commit", "-a", "-m", "Automatic update via push via Direct Workflow")
git.GitExecOrPanic(gitPrj, "commit", "-a", "-m", fmt.Sprintf("'%s' update via Direct Workflow", action.Repository.Name))
if !noop {
git.GitExecOrPanic(gitPrj, "push", remoteName)
}
return nil
return
}
log.Println("push of refs not on the configured branch", config.Branch, ". ignoring.")
return nil
common.LogDebug("push of refs not on the configured branch", branch, ". ignoring.")
}
func verifyProjectState(git common.Git, org string, config *common.AutogitConfig, configs []*common.AutogitConfig) (err error) {
@@ -248,51 +261,65 @@ func verifyProjectState(git common.Git, org string, config *common.AutogitConfig
remoteName, err := git.GitClone(gitPrj, gitBranch, repo.SSHURL)
common.PanicOnError(err)
defer git.GitExecOrPanic(gitPrj, "submodule", "deinit", "--all")
git.GitExecQuietOrPanic(gitPrj, "submodule", "deinit", "--all", "-f")
defer git.GitExecQuietOrPanic(gitPrj, "submodule", "deinit", "--all", "-f")
log.Println(" * Getting submodule list")
common.LogDebug(" * Getting submodule list")
sub, err := git.GitSubmoduleList(gitPrj, "HEAD")
common.PanicOnError(err)
log.Println(" * Getting package links")
common.LogDebug(" * Getting package links")
var pkgLinks []*PackageRebaseLink
if f, err := fs.Stat(os.DirFS(path.Join(git.GetPath(), gitPrj)), common.PrjLinksFile); err == nil && (f.Mode()&fs.ModeType == 0) && f.Size() < 1000000 {
if data, err := os.ReadFile(path.Join(git.GetPath(), gitPrj, common.PrjLinksFile)); err == nil {
pkgLinks, err = parseProjectLinks(data)
if err != nil {
log.Println("Cannot parse project links file:", err.Error())
common.LogError("Cannot parse project links file:", err.Error())
pkgLinks = nil
} else {
ResolveLinks(org, pkgLinks, gitea)
}
}
} else {
log.Println(" - No package links defined")
common.LogInfo(" - No package links defined")
}
/* Check existing submodule that they are updated */
isGitUpdated := false
next_package:
for filename, commitId := range sub {
// ignore project gits
//for _, c := range configs {
if gitPrj == filename {
log.Println(" prjgit as package? ignoring project git:", filename)
common.LogDebug(" prjgit as package? ignoring project git:", filename)
continue next_package
}
//}
log.Printf(" verifying package: %s -> %s(%s)", commitId, filename, config.Branch)
commits, err := gitea.GetRecentCommits(org, filename, config.Branch, 10)
if len(commits) == 0 {
if repo, err := gitea.GetRepository(org, filename); repo == nil && err == nil {
branch := config.Branch
common.LogDebug(" verifying package:", commitId, "->", filename, "@", branch)
if repo, err := gitea.GetRepository(org, filename); repo == nil && err == nil {
common.LogDebug(" repository removed...")
git.GitExecOrPanic(gitPrj, "rm", filename)
isGitUpdated = true
continue
} else if err != nil {
common.LogError("failed fetching repo data", org, filename, err)
continue
} else if len(branch) == 0 {
branch = repo.DefaultBranch
common.LogDebug(" -> using default branch", branch)
if common.IsRemovedBranch(branch) {
common.LogDebug(" Default branch for", filename, "is excluded")
git.GitExecOrPanic(gitPrj, "rm", filename)
isGitUpdated = true
continue
}
}
commits, err := gitea.GetRecentCommits(org, filename, branch, 10)
if err != nil {
log.Println(" -> failed to fetch recent commits for package:", filename, " Err:", err)
common.LogDebug(" -> failed to fetch recent commits for package:", filename, " Err:", err)
continue
}
@@ -309,7 +336,7 @@ next_package:
if l.Pkg == filename {
link = l
log.Println(" -> linked package")
common.LogDebug(" -> linked package")
// so, we need to rebase here. Can't really optimize, so clone entire package tree and remote
pkgPath := path.Join(gitPrj, filename)
git.GitExecOrPanic(gitPrj, "submodule", "update", "--init", "--checkout", filename)
@@ -323,7 +350,7 @@ next_package:
nCommits := len(common.SplitStringNoEmpty(git.GitExecWithOutputOrPanic(pkgPath, "rev-list", "^NOW", "HEAD"), "\n"))
if nCommits > 0 {
if !noop {
git.GitExecOrPanic(pkgPath, "push", "-f", "origin", "HEAD:"+config.Branch)
git.GitExecOrPanic(pkgPath, "push", "-f", "origin", "HEAD:"+branch)
}
isGitUpdated = true
}
@@ -340,42 +367,27 @@ next_package:
common.PanicOnError(git.GitExec(gitPrj, "submodule", "update", "--init", "--depth", "1", "--checkout", filename))
common.PanicOnError(git.GitExec(filepath.Join(gitPrj, filename), "fetch", "--depth", "1", "origin", commits[0].SHA))
common.PanicOnError(git.GitExec(filepath.Join(gitPrj, filename), "checkout", commits[0].SHA))
log.Println(" -> updated to", commits[0].SHA)
common.LogDebug(" -> updated to", commits[0].SHA)
isGitUpdated = true
} else {
// probably need `merge-base` or `rev-list` here instead, or the project updated already
log.Println(" *** Cannot find SHA of last matching update for package:", filename, " Ignoring")
common.LogInfo(" *** Cannot find SHA of last matching update for package:", filename, " Ignoring")
}
}
}
// find all missing repositories, and add them
if DebugMode {
log.Println("checking for missing repositories...")
}
common.LogDebug("checking for missing repositories...")
repos, err := gitea.GetOrganizationRepositories(org)
if err != nil {
return err
}
if DebugMode {
log.Println(" nRepos:", len(repos))
}
common.LogDebug(" nRepos:", len(repos))
/* Check repositories in org to make sure they are included in project git */
next_repo:
for _, r := range repos {
if DebugMode {
log.Println(" -- checking", r.Name)
}
if r.ObjectFormatName != "sha256" {
if DebugMode {
log.Println(" + ", r.ObjectFormatName, ". Needs to be sha256. Ignoring")
}
continue next_repo
}
// for _, c := range configs {
if gitPrj == r.Name {
// ignore project gits
@@ -390,43 +402,45 @@ next_repo:
}
}
if DebugMode {
log.Println(" -- checking repository:", r.Name)
}
common.LogDebug(" -- checking repository:", r.Name)
if _, err := gitea.GetRecentCommits(org, r.Name, config.Branch, 1); err != nil {
branch := config.Branch
if len(branch) == 0 {
branch = r.DefaultBranch
if common.IsRemovedBranch(branch) {
continue
}
}
if commits, err := gitea.GetRecentCommits(org, r.Name, branch, 1); err != nil || len(commits) == 0 {
// assumption that package does not exist, so not part of project
// https://github.com/go-gitea/gitea/issues/31976
// or, we do not have commits here
continue
}
// add repository to git project
common.PanicOnError(git.GitExec(gitPrj, "submodule", "--quiet", "add", "--depth", "1", r.CloneURL, r.Name))
common.PanicOnError(git.GitExec(gitPrj, "submodule", "--quiet", "add", "--force", "--depth", "1", r.CloneURL, r.Name))
if len(config.Branch) > 0 {
branch := strings.TrimSpace(git.GitExecWithOutputOrPanic(path.Join(gitPrj, r.Name), "branch", "--show-current"))
if branch != config.Branch {
if err := git.GitExec(path.Join(gitPrj, r.Name), "fetch", "--depth", "1", "origin", config.Branch+":"+config.Branch); err != nil {
return fmt.Errorf("Fetching branch %s for %s/%s failed. Ignoring.", config.Branch, repo.Owner.UserName, r.Name)
}
common.PanicOnError(git.GitExec(path.Join(gitPrj, r.Name), "checkout", config.Branch))
curBranch := strings.TrimSpace(git.GitExecWithOutputOrPanic(path.Join(gitPrj, r.Name), "branch", "--show-current"))
if branch != curBranch {
if err := git.GitExec(path.Join(gitPrj, r.Name), "fetch", "--depth", "1", "origin", branch+":"+branch); err != nil {
return fmt.Errorf("Fetching branch %s for %s/%s failed. Ignoring.", branch, repo.Owner.UserName, r.Name)
}
common.PanicOnError(git.GitExec(path.Join(gitPrj, r.Name), "checkout", branch))
}
isGitUpdated = true
}
if isGitUpdated {
common.PanicOnError(git.GitExec(gitPrj, "commit", "-a", "-m", "Automatic update via push via Direct Workflow -- SYNC"))
common.PanicOnError(git.GitExec(gitPrj, "commit", "-a", "-m", "Periodic SYNC in Direct Workflow"))
if !noop {
git.GitExecOrPanic(gitPrj, "push", remoteName)
}
}
if DebugMode {
log.Println("Verification finished for ", org, ", prjgit:", config.GitProjectName)
}
common.LogInfo("Verification finished for ", org, ", prjgit:", config.GitProjectName)
return nil
}
@@ -437,17 +451,17 @@ var checkInterval time.Duration
func checkOrg(org string, configs []*common.AutogitConfig) {
git, err := gh.CreateGitHandler(org)
if err != nil {
log.Println("Faield to allocate GitHandler:", err)
common.LogError("Failed to allocate GitHandler:", err)
return
}
defer git.Close()
for _, config := range configs {
log.Printf(" ++ starting verification, org: `%s` config: `%s`\n", org, config.GitProjectName)
common.LogInfo(" ++ starting verification, org:", org, "config:", config.GitProjectName)
if err := verifyProjectState(git, org, config, configs); err != nil {
log.Printf(" *** verification failed, org: `%s`, err: %#v\n", org, err)
common.LogError(" *** verification failed, org:", org, err)
} else {
log.Printf(" ++ verification complete, org: `%s` config: `%s`\n", org, config.GitProjectName)
common.LogError(" ++ verification complete, org:", org, config.GitProjectName)
}
}
}
@@ -456,7 +470,7 @@ func checkRepos() {
for org, configs := range configuredRepos {
if checkInterval > 0 {
sleepInterval := checkInterval - checkInterval/2 + time.Duration(rand.Int63n(int64(checkInterval)))
log.Println(" - sleep interval", sleepInterval, "until next check")
common.LogInfo(" - sleep interval", sleepInterval, "until next check")
time.Sleep(sleepInterval)
}
@@ -468,9 +482,9 @@ func consistencyCheckProcess() {
if checkOnStart {
savedCheckInterval := checkInterval
checkInterval = 0
log.Println("== Startup consistency check begin...")
common.LogInfo("== Startup consistency check begin...")
checkRepos()
log.Println("== Startup consistency check done...")
common.LogInfo("== Startup consistency check done...")
checkInterval = savedCheckInterval
}
@@ -485,7 +499,8 @@ var gh common.GitHandlerGenerator
func updateConfiguration(configFilename string, orgs *[]string) {
configFile, err := common.ReadConfigFile(configFilename)
if err != nil {
log.Fatal(err)
common.LogError(err)
os.Exit(4)
}
configs, _ := common.ResolveWorkflowConfigs(gitea, configFile)
@@ -493,9 +508,7 @@ func updateConfiguration(configFilename string, orgs *[]string) {
*orgs = make([]string, 0, 1)
for _, c := range configs {
if slices.Contains(c.Workflows, "direct") {
if DebugMode {
log.Printf(" + adding org: '%s', branch: '%s', prjgit: '%s'\n", c.Organization, c.Branch, c.GitProjectName)
}
common.LogDebug(" + adding org:", c.Organization, ", branch:", c.Branch, ", prjgit:", c.GitProjectName)
configs := configuredRepos[c.Organization]
if configs == nil {
configs = make([]*common.AutogitConfig, 0, 1)
@@ -509,7 +522,7 @@ func updateConfiguration(configFilename string, orgs *[]string) {
}
func main() {
configFilename := flag.String("config", "", "List of PrjGit")
configFilename := flag.String("config", "config.json", "List of PrjGit")
giteaUrl := flag.String("gitea-url", "https://src.opensuse.org", "Gitea instance")
rabbitUrl := flag.String("url", "amqps://rabbit.opensuse.org", "URL for RabbitMQ instance")
flag.BoolVar(&DebugMode, "debug", false, "Extra debugging information")
@@ -520,10 +533,35 @@ func main() {
flag.Parse()
if err := common.RequireGiteaSecretToken(); err != nil {
log.Fatal(err)
common.LogError(err)
os.Exit(1)
}
if err := common.RequireRabbitSecrets(); err != nil {
log.Fatal(err)
common.LogError(err)
os.Exit(1)
}
if cf := os.Getenv("AUTOGITS_CONFIG"); len(cf) > 0 {
*configFilename = cf
}
if url := os.Getenv("AUTOGITS_URL"); len(url) > 0 {
*giteaUrl = url
}
if url := os.Getenv("AUTOGITS_RABBITURL"); len(url) > 0 {
*rabbitUrl = url
}
if debug := os.Getenv("AUTOGITS_DEBUG"); len(debug) > 0 {
DebugMode = true
}
if check := os.Getenv("AUTOGITS_CHECK_ON_START"); len(check) > 0 {
checkOnStart = true
}
if p := os.Getenv("AUTOGITS_REPO_PATH"); len(p) > 0 {
*basePath = p
}
if DebugMode {
common.SetLoggingLevel(common.LogLevelDebug)
}
defs := &common.RabbitMQGiteaEventsProcessor{}
@@ -532,12 +570,14 @@ func main() {
if len(*basePath) == 0 {
*basePath, err = os.MkdirTemp(os.TempDir(), AppName)
if err != nil {
log.Fatal(err)
common.LogError(err)
os.Exit(1)
}
}
gh, err = common.AllocateGitWorkTree(*basePath, GitAuthor, GitEmail)
if err != nil {
log.Fatal(err)
common.LogError(err)
os.Exit(1)
}
// handle reconfiguration
@@ -552,10 +592,10 @@ func main() {
}
if sig != syscall.SIGHUP {
log.Println("Unexpected signal received:", sig)
common.LogError("Unexpected signal received:", sig)
continue
}
log.Println("*** Reconfiguring ***")
common.LogError("*** Reconfiguring ***")
updateConfiguration(*configFilename, &defs.Orgs)
defs.Connection().UpdateTopics(defs)
}
@@ -567,23 +607,25 @@ func main() {
gitea = common.AllocateGiteaTransport(*giteaUrl)
CurrentUser, err := gitea.GetCurrentUser()
if err != nil {
log.Fatalln("Cannot fetch current user:", err)
common.LogError("Cannot fetch current user:", err)
os.Exit(2)
}
log.Println("Current User:", CurrentUser.UserName)
common.LogInfo("Current User:", CurrentUser.UserName)
updateConfiguration(*configFilename, &defs.Orgs)
defs.Connection().RabbitURL, err = url.Parse(*rabbitUrl)
if err != nil {
log.Panicf("cannot parse server URL. Err: %#v\n", err)
common.LogError("cannot parse server URL. Err:", err)
os.Exit(3)
}
go consistencyCheckProcess()
log.Println("defs:", *defs)
common.LogInfo("defs:", *defs)
defs.Handlers = make(map[string]common.RequestProcessor)
defs.Handlers[common.RequestType_Push] = &PushActionProcessor{}
defs.Handlers[common.RequestType_Repository] = &RepositoryActionProcessor{}
log.Fatal(common.ProcessRabbitMQEvents(defs))
common.LogError(common.ProcessRabbitMQEvents(defs))
}

View File

@@ -1,53 +1,65 @@
Workflow-PR bot
===============
Keeps ProjectGit PR in-sync with a PackageGit PR
Areas of Responsibility
-----------------------
* Detects a PackageGit PR creation against a package and creates a coresponsing PR against the ProjectGit
* When a PackageGit PR is updated, the corresponding PR against the ProjectGit is updated
* Stores reference to the PackageGit PR in the headers of the ProjectGit PR comments, for later reference
* this allows ProjectGit PR to be merged to seperated later (via another tool, for example)
* Initiates all staging workflows via review requests
Keeps ProjectGit PRs in-sync with the relative PackageGit PRs.
Target Usage
------------
Any project (devel, etc) that accepts PR
Any project (devel, codestream, product, etc.) that accepts PRs.
Main Tasks
----------
* **Synchronization**:
* When a **PackageGit PR** is created for a package on a specific project branch, a corresponding PR is automatically generated in **ProjectGit**.
* When a PackageGit PR is updated, the corresponding PR against the ProjectGit is updated.
* A link to the PackageGit PR is stored in the body of the ProjectGit PR comments in the following format:
* `PR: organization/package_name!pull_request_number`
* Example: `PR: pool/curl!4`
* It closes an empty ProjectGit PR (e.g., if a PR was initially created for a single package but later integrated into a larger ProjectGit PR).
* It forwards the Work In Progress (WIP) flag to the ProjectGit PR. If the ProjectGit PR references multiple Package PRs, triggering the WIP flag on the ProjectGit PR side only requires a single WIP package PR.
* **Reviewer Management**:
* It adds required reviewers in the ProjectGit PR.
* It adds required reviewers in the PackageGit PR.
* If new commits are added to a PackageGit PR, reviewers who have already approved it will be re-added.
* **Merge Management**:
* Manages PR merges based on configuration flags (`ManualMergeOnly`, `ManualMergeProject`).
* In general, merge only happens if all mandatory reviews are completed.
* **ManualMergeProject** is stricter than **ManualMergeOnly** and has higher priority.
| Flag | Value | Behavior |
| ----- | ----- | ----- |
| ManualMergeProject | true | Both ProjectGit and PackageGit PRs are merged upon an allowed project maintainer commenting "merge ok” in the ProjectGit PR. |
| ManualMergeOnly | true | Both PackageGit PR and ProjectGit PR are merged upon an allowed package maintainer or project maintainer commenting “merge ok” in the PackageGit PR. |
| ManualMergeOnly and ManualMergeProject | false | Both ProjectGit and PackageGit PRs are merged as soon as all reviews are completed in both PrjGit and PkgGit PRs. |
Config file
-----------
JSON
* _Workflows_: ["pr"] -- pr entry enables pr workflow. **Mandatory**
* _Organization_: organization that holds all the packages **Mandatory**
* _Branch_: branch updated in repo's **Mandatory**
* _GitProjectName_: package in above org, or `org/package#branch` for PrjGit. By default assumes `_ObsPrj` with default branch and in the `Organization`
* _Reviewers_: accounts associated with mandatory reviews for PrjGit. Can trigger additional
review requests for PrjGit or associated PkgGit repos. Only when all reviews are
satisfied, will the PrjGit PR be merged. See Reviewers below.
* _ManualMergeOnly_: (true, false) only merge if "merge ok" comment/review by package or project maintainers or reviewers
* _ManualMergeProject_: (true, false) only merge if "merge ok" by project maintainers or reviewers
* _ReviewRequired_: (true, false) ignores that submitter is a maintainer and require a review from other maintainer IFF available
* _NoProjectGitPR_: (true, false) do not create PrjGit PRs, but still process reviews, etc.
NOTE: `-rm`, `-removed`, `-deleted` are all removed suffixes used to indicate current branch is a placeholder for previously existing package. These branches will be ignored by the bot, and if default, the package will be removed and will not be added to the project.
example:
* Filename: `workflow.config`
* Location: ProjectGit
* Format: non-standard JSON (comments allowed)
| Field name | Details | Mandatory | Type | Allowed Values | Default |
| ----- | ----- | ----- | ----- | ----- | ----- |
| *Workflows* | Type of workflow | yes | string | “pr” | |
| *Organization* | The organization where PackageGit PRs are expected to occur | yes | string | | |
| *Branch* | The designated branch for PackageGit PRs | yes | string | | |
| *GitProjectName* | Repository and branch where the ProjectGit lives. | no | string | **Format**: `org/project_repo#branch` | By default assumes `_ObsPrj` with default branch in the *Organization* |
| *ManualMergeOnly* | Merges are permitted only upon receiving a "merge ok" comment from designated maintainers in the PkgGit PR. | no | bool | true, false | false |
| *ManualMergeProject* | Merges are permitted only upon receiving a "merge ok" comment in the ProjectGit PR from project maintainers. | no | bool | true, false | false |
| *ReviewRequired* | (NOT IMPLEMENTED) If submitter is a maintainer, require review from another maintainer if available. | no | bool | true, false | false |
| *NoProjectGitPR* | Do not create PrjGit PR, but still perform other tasks. | no | bool | true, false | false |
| *Reviewers* | PrjGit reviewers. Additional review requests are triggered for associated PkgGit PRs. PrjGit PR is merged only when all reviews are complete. | no | array of strings | | `[]` |
| *ReviewGroups* | If a group is specified in Reviewers, its members are listed here. | no | array of objects | | `[]` |
| *ReviewGroups > Name* | Name of the group | no | string | | |
| *ReviewGroups > Reviewers* | Members of the group | no | array of strings | | |
| *ReviewGroups > Silent* | Add members for notifications. If true, members are not explicitly requested to review. If one member approves, others are removed. | no | bool | true, false | false |
[
{
"Workflows": ["pr", "direct"],
"Organization": "autogits",
"GitProjectName": "HiddenPrj",
"Branch": "hidden",
"Reviewers": []
},
...
]
Reviewers
---------
@@ -56,36 +68,96 @@ Reviews is a list of accounts that need to review package and/or project. They h
[~][*|-|+]username
General prefix of ~ indicates advisory reviewer. They will be requested, but ignored otherwise.
A tilde (`~`) before a prefix signifies an advisory reviewer. Their input is requested, but their review status will not otherwise affect the process.
Other prefixes indicate project or package association of the reviewer:
* `*` indicates project *and* package
* `-` indicates project-only reviewer
* `+` indicates package-only reviewer
`+` is implied. For example
`+` is implied.
`[foo, -bar, ~*moo]`
For example: `[foo, -bar, ~*moo]` results in:
* foo: package reviews
* bar: project reviews
* moo: package and project reviews, but ignored
results in
* foo -> package reviews
* bar -> project reviews
* moo -> package and project reviews, but ignored
Package Deletion Requests
-------------------------
(NOT YET IMPLEMENTED)
* **Removing a Package:**
To remove a package from a project, submit a ProjectGit Pull Request (PR) that removes the corresponding submodule. The bot will then rename the project branch in the pool by appending "-removed" to its name.
* **Adding a Package Again:**
If you wish to re-add a package, create a new PrjGit PR which adds again the submodule on the branch that has the "-removed" suffix. The bot will automatically remove this suffix from the project branch in the pool.
Labels
------
The following labels are used, when defined in Repo/Org.
| Label Config Entry | Default label | Description
|--------------------|----------------|----------------------------------------
| StagingAuto | staging/Auto | Assigned to Project Git PRs when first staged
| ReviewPending | review/Pending | Assigned to Project Git PR when package reviews are still pending
| ReviewDone | review/Done | Assigned to Project Git PR when reviews are complete on all package PRs
Maintainership
--------------
Maintainership information is defined per project. For reviews, package maintainers are coalesced
with project maintainers. A review by any of the maintainers is acceptable.
Filename: \_maintainership.json
Location: ProjectGit
Format: JSON
Fields:
example:
| Key | Value | Notes |
| ----- | ----- | ----- |
| package name | array of strings representing the package maintainers | List of package maintainers |
| “” (empty string) | array of strings representing the project maintainers | List of project maintainers |
{
"package1": [ "reviewer", "reviewer2"],
"package2": [],
Maintainership information is defined per project. For PackageGit PR reviews, package maintainers are combined with project maintainers. A review by any of these maintainers is acceptable.
// "project" maintainer
"": ["reviewer3", "reviewer4"]
If the submitter is a maintainer it will not get a review requested.
Example:
```
{
"package1": [ "reviewer", "reviewer2"],
"package2": [],
// "project" maintainer
"": ["reviewer3", "reviewer4"]
}
```
Permissions
-----------
Permissions are extra permissions assigned to groups or individuals. Groups must be defined in
the `workflow.config`.
```
Permissions: []{
Permission: "force-push" | "release-engineering"
Members: []string
}
```
* `force-push` -- allows to issue force-push to the bot to merge even without reviews
* `release-engineering` -- merge, split package PRs and merge additional commits
NOTE: Project Maintainers have these permissions automatically.
Server configuration
--------------------------
**Configuration file:**
| Field | Type | Notes |
| ----- | ----- | ----- |
| root | Array of string | Format **org/repo\#branch** |

View File

@@ -7,7 +7,7 @@ import "src.opensuse.org/autogits/common"
type StateChecker interface {
VerifyProjectState(configs *common.AutogitConfig) ([]*PRToProcess, error)
CheckRepos() error
CheckRepos()
ConsistencyCheckProcess() error
}

View File

@@ -170,7 +170,7 @@ func main() {
common.RequestType_PRSync: req,
common.RequestType_PRReviewAccepted: req,
common.RequestType_PRReviewRejected: req,
common.RequestType_IssueComment: req,
common.RequestType_PRComment: req,
},
}
listenDefs.Connection().RabbitURL, _ = url.Parse(*rabbitUrl)

View File

@@ -4,11 +4,13 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"path"
"runtime/debug"
"slices"
"strings"
"time"
"github.com/opentracing/opentracing-go/log"
"src.opensuse.org/autogits/common"
@@ -39,6 +41,9 @@ func PrjGitDescription(prset *common.PRSet) (title string, desc string) {
refs = append(refs, ref)
}
slices.Sort(title_refs)
slices.Sort(refs)
title = "Forwarded PRs: " + strings.Join(title_refs, ", ")
desc = fmt.Sprintf("This is a forwarded pull request by %s\nreferencing the following pull request(s):\n\n", GitAuthor) + strings.Join(refs, "\n") + "\n"
@@ -225,12 +230,18 @@ func (pr *PRProcessor) CreatePRjGitPR(prjGitPRbranch string, prset *common.PRSet
}
title, desc := PrjGitDescription(prset)
pr, err := Gitea.CreatePullRequestIfNotExist(PrjGit, prjGitPRbranch, PrjGitBranch, title, desc)
pr, err, isNew := Gitea.CreatePullRequestIfNotExist(PrjGit, prjGitPRbranch, PrjGitBranch, title, desc)
if err != nil {
common.LogError("Error creating PrjGit PR:", err)
return err
}
Gitea.UpdatePullRequest(PrjGit.Owner.UserName, PrjGit.Name, pr.Index, &models.EditPullRequestOption{
org := PrjGit.Owner.UserName
repo := PrjGit.Name
idx := pr.Index
if isNew {
Gitea.SetLabels(org, repo, idx, []string{prset.Config.Label(common.Label_StagingAuto)})
}
Gitea.UpdatePullRequest(org, repo, idx, &models.EditPullRequestOption{
RemoveDeadline: true,
})
@@ -266,6 +277,8 @@ func (pr *PRProcessor) RebaseAndSkipSubmoduleCommits(prset *common.PRSet, branch
return nil
}
var updatePrjGitError_requeue error = errors.New("Commits do not match. Requeing after 5 seconds.")
func (pr *PRProcessor) UpdatePrjGitPR(prset *common.PRSet) error {
_, _, PrjGitBranch := prset.Config.GetPrjGit()
PrjGitPR, err := prset.GetPrjGitPR()
@@ -283,7 +296,7 @@ func (pr *PRProcessor) UpdatePrjGitPR(prset *common.PRSet) error {
PrjGit := PrjGitPR.PR.Base.Repo
prjGitPRbranch := PrjGitPR.PR.Head.Name
if strings.Contains(prjGitPRbranch, "/") {
if PrjGitPR.PR.Base.RepoID != PrjGitPR.PR.Head.RepoID {
PrjGitPR.RemoteName, err = git.GitClone(common.DefaultGitPrj, "", PrjGit.SSHURL)
git.GitExecOrPanic(common.DefaultGitPrj, "fetch", PrjGitPR.RemoteName, PrjGitPR.PR.Head.Sha)
git.GitExecOrPanic(common.DefaultGitPrj, "checkout", PrjGitPR.PR.Head.Sha)
@@ -328,16 +341,34 @@ func (pr *PRProcessor) UpdatePrjGitPR(prset *common.PRSet) error {
}
if !common.IsDryRun {
if headCommit != PrjGitPR.PR.Head.Sha {
common.LogError("HeadCommit:", headCommit, "is not what's expected from the PR:", PrjGitPR.PR.Head.Ref, " Requeing.")
return updatePrjGitError_requeue
}
if headCommit != newHeadCommit {
params := []string{"push", PrjGitPR.RemoteName, "+HEAD:" + prjGitPRbranch}
if forcePush {
params = slices.Insert(params, 1, "-f")
}
common.PanicOnError(git.GitExec(common.DefaultGitPrj, params...))
PrjGitPR.PR.Head.Sha = newHeadCommit
}
// update PR
if PrjGitPR.PR.Body != PrjGitBody || PrjGitPR.PR.Title != PrjGitTitle {
isPrTitleSame := func(CurrentTitle, NewTitle string) bool {
ctlen := len(CurrentTitle)
for _, suffix := range []string{"...", "…"} {
slen := len(suffix)
if ctlen > 250 && strings.HasSuffix(CurrentTitle, suffix) && len(NewTitle) > ctlen {
NewTitle = NewTitle[0:ctlen-slen] + suffix
if CurrentTitle == NewTitle {
return true
}
}
}
return CurrentTitle == NewTitle
}
if PrjGitPR.PR.User.UserName == CurrentUser.UserName && (PrjGitPR.PR.Body != PrjGitBody || !isPrTitleSame(PrjGitPR.PR.Title, PrjGitTitle)) {
Gitea.UpdatePullRequest(PrjGit.Owner.UserName, PrjGit.Name, PrjGitPR.PR.Index, &models.EditPullRequestOption{
RemoveDeadline: true,
Title: PrjGitTitle,
@@ -373,6 +404,10 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
prjGitPRbranch := prGitBranchNameForPR(prRepo, prNo)
prjGitPR, err := prset.GetPrjGitPR()
if err == common.PRSet_PrjGitMissing {
if req.State != "open" {
common.LogDebug("This PR is closed and no ProjectGit PR. Ignoring.")
return nil
}
common.LogDebug("Missing PrjGit. Need to create one under branch", prjGitPRbranch)
if err = pr.CreatePRjGitPR(prjGitPRbranch, prset); err != nil {
@@ -519,6 +554,14 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
return err
}
// update prset if we should build it or not
if prjGitPR != nil {
if file, err := git.GitCatFile(common.DefaultGitPrj, prjGitPR.PR.Head.Sha, "staging.config"); err == nil {
prset.HasAutoStaging = (file != nil)
common.LogDebug(" -> automatic staging enabled?:", prset.HasAutoStaging)
}
}
// handle case where PrjGit PR is only one left and there are no changes, then we can just close the PR
if len(prset.PRs) == 1 && prjGitPR != nil && prset.PRs[0] == prjGitPR && prjGitPR.PR.User.UserName == prset.BotUser {
common.LogDebug(" --> checking if superflous PR")
@@ -564,6 +607,7 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
type RequestProcessor struct {
configuredRepos map[string][]*common.AutogitConfig
recursive int
}
func ProcesPullRequest(pr *models.PullRequest, configs []*common.AutogitConfig) error {
@@ -582,23 +626,29 @@ func ProcesPullRequest(pr *models.PullRequest, configs []*common.AutogitConfig)
return PRProcessor.Process(pr)
}
func (w *RequestProcessor) ProcessFunc(request *common.Request) error {
func (w *RequestProcessor) ProcessFunc(request *common.Request) (err error) {
defer func() {
if r := recover(); r != nil {
common.LogInfo("panic cought --- recovered")
common.LogError(string(debug.Stack()))
}
w.recursive--
}()
w.recursive++
if w.recursive > 3 {
common.LogError("Recursion limit reached... something is wrong with this PR?")
return nil
}
var pr *models.PullRequest
var err error
if req, ok := request.Data.(*common.PullRequestWebhookEvent); ok {
pr, err = Gitea.GetPullRequest(req.Pull_Request.Base.Repo.Owner.Username, req.Pull_Request.Base.Repo.Name, req.Pull_Request.Number)
if err != nil {
common.LogError("Cannot find PR for issue:", req.Pull_Request.Base.Repo.Owner.Username, req.Pull_Request.Base.Repo.Name, req.Pull_Request.Number)
return err
}
} else if req, ok := request.Data.(*common.IssueWebhookEvent); ok {
} else if req, ok := request.Data.(*common.IssueCommentWebhookEvent); ok {
pr, err = Gitea.GetPullRequest(req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
if err != nil {
common.LogError("Cannot find PR for issue:", req.Repository.Owner.Username, req.Repository.Name, int64(req.Issue.Number))
@@ -613,5 +663,9 @@ func (w *RequestProcessor) ProcessFunc(request *common.Request) error {
if !ok {
common.LogError("*** Cannot find config for org:", pr.Base.Repo.Owner.UserName)
}
return ProcesPullRequest(pr, configs)
if err = ProcesPullRequest(pr, configs); err == updatePrjGitError_requeue {
time.Sleep(time.Second * 5)
return w.ProcessFunc(request)
}
return err
}

View File

@@ -103,7 +103,7 @@ func TestOpenPR(t *testing.T) {
}
// gitea.EXPECT().GetAssociatedPrjGitPR("test", "prjcopy", "test", "testRepo", int64(1)).Return(nil, nil)
gitea.EXPECT().CreateRepositoryIfNotExist(git, "test", "prjcopy").Return(prjgit, nil)
gitea.EXPECT().CreatePullRequestIfNotExist(prjgit, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(giteaPR, nil)
gitea.EXPECT().CreatePullRequestIfNotExist(prjgit, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(giteaPR, nil, true)
gitea.EXPECT().GetPullRequest("test", "testRepo", int64(1)).Return(giteaPR, nil)
gitea.EXPECT().RequestReviews(giteaPR, "reviewer1", "reviewer2").Return(nil, nil)
gitea.EXPECT().GetPullRequestReviews("test", "testRepo", int64(0)).Return([]*models.PullReview{}, nil)
@@ -153,7 +153,7 @@ func TestOpenPR(t *testing.T) {
}
failedErr := errors.New("Returned error here")
gitea.EXPECT().CreateRepositoryIfNotExist(git, "test", "prjcopy").Return(prjgit, nil)
gitea.EXPECT().CreatePullRequestIfNotExist(prjgit, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, failedErr)
gitea.EXPECT().CreatePullRequestIfNotExist(prjgit, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, failedErr, false)
err := pr.Process(event)
if err != failedErr {
@@ -193,7 +193,7 @@ func TestOpenPR(t *testing.T) {
gitea.EXPECT().CreateRepositoryIfNotExist(git, "test", "prjcopy").Return(prjgit, nil)
gitea.EXPECT().GetPullRequest("test", "testRepo", int64(1)).Return(giteaPR, nil)
gitea.EXPECT().GetPullRequestReviews("org", "SomeRepo", int64(13)).Return([]*models.PullReview{}, nil)
gitea.EXPECT().CreatePullRequestIfNotExist(prjgit, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(giteaPR, nil)
gitea.EXPECT().CreatePullRequestIfNotExist(prjgit, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(giteaPR, nil, true)
gitea.EXPECT().RequestReviews(giteaPR, "reviewer1", "reviewer2").Return(nil, failedErr)
gitea.EXPECT().FetchMaintainershipDirFile("test", "prjcopy", "branch", "_project").Return(nil, "", repository.NewRepoGetRawFileNotFound())

View File

@@ -1,7 +1,6 @@
package main
import (
"errors"
"fmt"
"math/rand"
"path"
@@ -43,6 +42,15 @@ func pullRequestToEventState(state models.StateType) string {
}
func (s *DefaultStateChecker) ProcessPR(pr *models.PullRequest, config *common.AutogitConfig) error {
defer func() {
if r := recover(); r != nil {
common.LogError("panic caught in ProcessPR", common.PRtoString(pr))
if err, ok := r.(error); !ok {
common.LogError(err)
}
common.LogError(string(debug.Stack()))
}
}()
return ProcesPullRequest(pr, common.AutogitConfigs{config})
}
@@ -151,7 +159,7 @@ func (s *DefaultStateChecker) VerifyProjectState(config *common.AutogitConfig) (
return PrjGitSubmoduleCheck(config, git, prjGitRepo, submodules)
}
func (s *DefaultStateChecker) CheckRepos() error {
func (s *DefaultStateChecker) CheckRepos() {
defer func() {
if r := recover(); r != nil {
common.LogError("panic caught")
@@ -161,7 +169,6 @@ func (s *DefaultStateChecker) CheckRepos() error {
common.LogError(string(debug.Stack()))
}
}()
errorList := make([]error, 0, 10)
for org, configs := range s.processor.configuredRepos {
for _, config := range configs {
@@ -175,12 +182,12 @@ func (s *DefaultStateChecker) CheckRepos() error {
prs, err := s.i.VerifyProjectState(config)
if err != nil {
common.LogError(" *** verification failed, org:", org, err)
errorList = append(errorList, err)
}
for _, pr := range prs {
prs, err := Gitea.GetRecentPullRequests(pr.Org, pr.Repo, pr.Branch)
if err != nil {
return fmt.Errorf("Error fetching pull requests for %s/%s#%s. Err: %w", pr.Org, pr.Repo, pr.Branch, err)
common.LogError("Error fetching pull requests for", fmt.Sprintf("%s/%s#%s", pr.Org, pr.Repo, pr.Branch), err)
break
}
if len(prs) > 0 {
common.LogDebug(fmt.Sprintf("%s/%s#%s", pr.Org, pr.Repo, pr.Branch), " - # of PRs to check:", len(prs))
@@ -193,9 +200,11 @@ func (s *DefaultStateChecker) CheckRepos() error {
common.LogInfo(" ++ verification complete, org:", org, "config:", config.GitProjectName)
}
}
return errors.Join(errorList...)
if len(configs) == 0 {
common.LogError(" org:", org, "has 0 configs?")
}
}
}
func (s *DefaultStateChecker) ConsistencyCheckProcess() error {