1
0

129 Commits

Author SHA256 Message Date
9f9a4660e9 spec: no @ in service names 2026-02-19 12:34:58 +01:00
cb2f17a287 systemd: do not use dynamic user 2026-02-19 12:10:11 +01:00
3125df4d6a Merge branch 'main' into add_integration_folder 2026-02-18 23:11:37 +01:00
06600813b4 pr: add systemd file for workflow-pr
also fix the workflow-direct scriptlet
2026-02-18 20:17:47 +01:00
3b510182d6 pr: Add environmental defaults like in workflow-direct 2026-02-18 20:09:44 +01:00
Andrii Nikitin
d1bcc222ce Move fixtures from container-files to pytest tearup 2026-02-17 18:23:57 +01:00
Andrii Nikitin
b632952f62 Add integration tests 2026-02-16 21:40:58 +01:00
Bernhard M. Wiedemann
1b90299d94 obs-staging-bot: Add missing return 2026-02-16 17:52:32 +01:00
708add1017 status: unknown code should be an error log 2026-02-06 23:25:33 +01:00
712349d638 status: always link to build log 2026-02-06 23:09:29 +01:00
ba5a42dd29 staging: Adapt commit status link when QA fails
but main project was successful (eg. package builds, but appliance
fails, eg. due to broken deps)
2026-02-06 20:32:28 +01:00
53cf2c8bad staging: Fix repeated QA project setups
Don't touch an already setup project
2026-02-06 20:32:00 +01:00
868c28cd5a staging: Allow host names via env file
or we can run it only for OBS via packages...
2026-02-06 20:30:40 +01:00
962c4b2562 staging: Set result to failed when we have missing packages
in that case no build failure may appear so it was handled as success

https://github.com/openSUSE/openSUSE-git/issues/228
2026-02-06 20:29:07 +01:00
57cb251dbc staging: Protection against broken staging.config
when having multiple projects  with same name
2026-02-06 20:28:49 +01:00
75c4fada50 staging: Support using source from pullrequest in QA project
A QA project can get configured to also rebuild the sources again based
on the binaries of other packages of the QA project.

This is esp. useful for bootstrap projects. We would keep the main
project in this case to ensure that the package itself is building
on current code base. But we would also test if it breaks other packages
or fail during bootstrap cycle.
2026-02-06 20:28:21 +01:00
7d13e586ac Support QA projects depend on each other 2026-02-06 20:27:51 +01:00
7729b845b0 Fix project meta rendering
* support project links
* ommit scmsync if empty
2026-02-06 20:27:28 +01:00
c662b2fdbf staging: Add support for filtering QA projects via Labels 2026-02-06 20:26:38 +01:00
Antonello Tartamo
4cedb37da4 fixed check for multiple repos and architectures, added a link to the OBS project 2026-02-06 20:26:09 +01:00
Antonello Tartamo
fe519628c8 fixed spamming comments on package PRs when build is in progress 2026-02-06 20:25:49 +01:00
Antonello Tartamo
ff18828692 Forward build status to package PR/s 2026-02-06 20:25:21 +01:00
6337ef7e50 staging: drop compare of build results of reference projects
it did not work reliable and is actually not wanted by SLFO release
managers
2026-02-06 20:20:20 +01:00
e9992d2e99 obs-staging: fix onlybuild for subdirectories
The parameter only takes a package name, but not a full subdirectory
string. So we hand over only the last part behind a / here
2026-02-06 20:19:59 +01:00
aac218fc6d spec: typo fix 2026-02-06 20:04:52 +01:00
139f40fce3 pr: fix maintainership data corruption
With group expansion, we were not operating on a copy of the slice
but the original slice. This results in the original data being
modified instead of the copy as intended.
2026-02-06 16:56:03 +01:00
c44d34fdbe staging: handle OBS API downtime
don't write "Cannot fetch reference project meta" endless
2026-02-05 16:45:24 +01:00
23be3df1fb common: fix timeline cache invalidation 2026-02-03 17:11:24 +01:00
68b67c6975 Merge branch 'maintainer-update' 2026-02-03 13:10:16 +01:00
478a3a140a utils: sync data when writing new maintainership file 2026-02-03 13:08:56 +01:00
df4da87bfd migration: update migrated project 2026-02-03 12:51:46 +01:00
b19d301d95 util: move flag parsing under run() 2026-02-03 12:20:13 +01:00
9532aa897c utils: add test runner to spec build process 2026-02-03 11:32:22 +01:00
f942909ac7 utils: test naming fix 2026-02-03 11:30:56 +01:00
7f98298b89 Transfer WIP: from package pool requests to project 2026-02-02 11:14:07 +01:00
c6ee055cb4 pr: add unit test for PrjGit PR to make sure we clone it
This is unit test for previous commit
2026-01-30 16:23:25 +01:00
58e5547a91 pr: fix case where PrjGit not cloned
When the PRSet is of size 1, so only PrjGit, the project git
may not be cloned. This breaks build preperations, etc.
2026-01-30 16:02:13 +01:00
c2709e1894 fix unit tests and mocks 2026-01-28 10:50:36 +01:00
7790e5f301 Merge branch 'main' into always-review-nt 2026-01-27 15:45:12 +01:00
2620aa3ddd Merge branch 'always-review' 2026-01-27 15:44:30 +01:00
59a47cd542 Merge branch 'pr-tests' 2026-01-27 13:41:34 +01:00
a0c51657d4 pr: reset timeline cache when fetching PRSet 2026-01-26 15:34:46 +01:00
f0b053ca07 utils: add maintainer-update to utils 2026-01-26 09:22:51 +01:00
844ec8a87b util: fix 2026-01-24 21:56:52 +01:00
6ee8fcc597 utils: add unit tests 2026-01-24 21:47:45 +01:00
1220799e57 util: add maintainership linter 2026-01-24 19:22:15 +01:00
86a176a785 common: precise key removal 2026-01-24 18:11:43 +01:00
bb9e9a08e5 common: only change maintainership lines that changed 2026-01-24 17:52:00 +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
877e93c9bf pr: always require review, if configured
Implement ReviewRequired option to workflow.config. This will
always require a review by maintainer, unless no other maintainers
are available.

By default, ReviewRequired is false
2026-01-20 19:18:56 +01:00
51403713be pr: always require review, if configured
Implement ReviewRequired option to workflow.config. This will
always require a review by maintainer, unless no other maintainers
are available.

By default, AlwaysRequireReview is false
2026-01-20 19:17:10 +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
f959684540 pr: interfaces moved to main package 2026-01-10 00:57:22 +01:00
18f7ed658a pr: move interfaces and mocks to parent package 2026-01-10 00:40:45 +01:00
c05fa236d1 pr: Add additional unit tests
- Add a test case specifically verifying that `Gitea.SetLabels`
  is called with `staging/Auto` when a *new* project PR is created
  for submodules.
- Verify `PrjGitDescription` and `SetSubmodulesToMatchPRSet` behave
  correctly when a single `PRSet` contains 5+ different package
  repositories.
2026-01-09 18:56:33 +01:00
c866303696 pr: fix PR lists to check packages not just project PRs
Also,
- Add simple unit tests to verify mapping of `models.StateType`
  to internal event strings.
- Verify it correctly wraps `ProcesPullRequest` and handles panics
  via the deferred recovery block.
- Add tests for scenarios where `GetRecentPullRequests` fails.
- Verify the random sleep interval logic (can be tested by mocking
  `time.Sleep` if refactored, or verifying behavior with interval=0).
2026-01-09 17:48:01 +01:00
e806d6ad0d pr: revive PRProcessor sync tests
- Uncomment and fix the existing tests for `synchronized` actions.
- Ensure it uses the new `PullRequestProcessor` interface and mocked dependencies.
2026-01-09 17:12:14 +01:00
abf8aa58fc pr: test PRProcessor that is triggered by webhook
- PullRequestWebhookEvent: Verified that PR events correctly
  trigger processing with all necessary Gitea and Git mocks.
- IssueCommentWebhookEvent: Verified that issue comment events
  (which Gitea often uses for PR comments) are handled correctly.
- Recursion Limit: Verified that the recursion protection logic
  correctly terminates and cleans up when the limit is reached.
- Invalid Data Format: Verified that non-event data types return
  appropriate errors.
2026-01-09 16:59:29 +01:00
4f132ec154 pr: test verifyRepositoryConfiguration 2026-01-09 16:41:25 +01:00
86a7fd072e pr: add test cases for PRProcessor corner cases
- Add scenarios for closed/merged project PRs that trigger
   submodule checks and downstream PR updates (manual merge vs close).
- Test the "Consistency check" logic where submodules are reset
  if they don't match the PR set.
- Test the "superfluous PR" check (no-op PRs that should be closed).
2026-01-09 16:34:07 +01:00
5f5e7d98b5 pr: add some tests for UpdatePrjGitPR 2026-01-09 13:59:02 +01:00
e8738c9585 pr: add tests for RebaseAndSkipSubmoduleCommits 2026-01-09 13:44:53 +01:00
2f18adaa67 pr: move common test helpers to dedicated area 2026-01-09 12:57:42 +01:00
b7f5c97de1 pr: add error handling unit tests 2026-01-08 22:21:33 +01:00
09001ce01b pr: repo_check unit tests 2026-01-08 21:02:18 +01:00
37c9cc7a57 add PRProcessor tests 2026-01-08 20:57:18 +01:00
362e481a09 pr: fix unit tests 2026-01-08 17:54:00 +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
99 changed files with 7985 additions and 1293 deletions

View File

@@ -23,7 +23,6 @@ jobs:
- 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)

View File

@@ -12,7 +12,6 @@ jobs:
- 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://}

5
.gitignore vendored
View File

@@ -1,2 +1,7 @@
*.osc
*.conf
/integration/gitea-data
/integration/gitea-logs
/integration/rabbitmq-data
/integration/workflow-pr-repos
__pycache__/

4
Makefile Normal file
View File

@@ -0,0 +1,4 @@
MODULES := devel-importer utils/hujson utils/maintainer-update gitea-events-rabbitmq-publisher gitea_status_proxy group-review obs-forward-bot obs-staging-bot obs-status-service workflow-direct workflow-pr
build:
for m in $(MODULES); do go build -C $$m -buildmode=pie || exit 1 ; done

View File

@@ -17,7 +17,7 @@
Name: autogits
Version: 0
Version: 1
Release: 0
Summary: GitWorkflow utilities
License: GPL-2.0-or-later
@@ -41,6 +41,7 @@ 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
@@ -56,10 +57,11 @@ with a topic
%package gitea-status-proxy
Summary: gitea-status-proxy
Summary: Proxy for setting commit status in Gitea
%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 group-review
Summary: Reviews of groups defined in ProjectGit
@@ -127,6 +129,9 @@ go build \
go build \
-C utils/hujson \
-buildmode=pie
go build \
-C utils/maintainer-update \
-buildmode=pie
go build \
-C gitea-events-rabbitmq-publisher \
-buildmode=pie
@@ -158,6 +163,7 @@ 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
go test -C utils/maintainer-update
# TODO build fails
#go test -C workflow-pr -v
@@ -167,6 +173,7 @@ install -D -m0755 gitea-events-rabbitmq-publisher/gitea-events-rabbitmq-publishe
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
@@ -175,7 +182,9 @@ install -D -m0644 systemd/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 -m0644 systemd/workflow-pr@.service %{buildroot}%{_unitdir}/workflow-pr@.service
install -D -m0755 utils/hujson/hujson %{buildroot}%{_bindir}/hujson
install -D -m0755 utils/maintainer-update/maintainer-update %{buildroot}%{_bindir}/maintainer-update
%pre gitea-events-rabbitmq-publisher
%service_add_pre gitea-events-rabbitmq-publisher.service
@@ -189,6 +198,18 @@ install -D -m0755 utils/hujson/hujson
%postun gitea-events-rabbitmq-publisher
%service_del_postun gitea-events-rabbitmq-publisher.service
%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
@@ -213,6 +234,30 @@ install -D -m0755 utils/hujson/hujson
%postun obs-status-service
%service_del_postun obs-status-service.service
%pre workflow-direct
%service_add_pre workflow-direct.service
%post workflow-direct
%service_add_post workflow-direct.service
%preun workflow-direct
%service_del_preun workflow-direct.service
%postun workflow-direct
%service_del_postun workflow-direct.service
%pre workflow-pr
%service_add_pre workflow-pr.service
%post workflow-pr
%service_add_post workflow-pr.service
%preun workflow-pr
%service_del_preun workflow-pr.service
%postun workflow-pr
%service_del_postun workflow-pr.service
%files devel-importer
%license COPYING
%doc devel-importer/README.md
@@ -237,6 +282,7 @@ install -D -m0755 utils/hujson/hujson
%license COPYING
%doc group-review/README.md
%{_bindir}/group-review
%{_unitdir}/group-review@.service
%files obs-forward-bot
%license COPYING
@@ -257,6 +303,7 @@ install -D -m0755 utils/hujson/hujson
%files utils
%license COPYING
%{_bindir}/hujson
%{_bindir}/maintainer-update
%files workflow-direct
%license COPYING
@@ -268,4 +315,5 @@ install -D -m0755 utils/hujson/hujson
%license COPYING
%doc workflow-pr/README.md
%{_bindir}/workflow-pr
%{_unitdir}/workflow-pr@.service

View File

@@ -54,6 +54,8 @@ type ReviewGroup struct {
type QAConfig struct {
Name string
Origin string
Label string // requires this gitea lable to be set or skipped
BuildDisableRepos []string // which repos to build disable in the new project
}
type Permissions struct {
@@ -61,6 +63,20 @@ type Permissions struct {
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
@@ -72,9 +88,12 @@ type AutogitConfig struct {
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
ReviewRequired bool // always require a maintainer review, even if maintainer submits it. Only ignored if no other package or project reviewers
}
type AutogitConfigs []*AutogitConfig
@@ -188,6 +207,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
}
@@ -273,6 +294,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

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

View File

@@ -20,10 +20,13 @@ package common
const (
GiteaTokenEnv = "GITEA_TOKEN"
GiteaHostEnv = "GITEA_HOST"
ObsUserEnv = "OBS_USER"
ObsPasswordEnv = "OBS_PASSWORD"
ObsSshkeyEnv = "OBS_SSHKEY"
ObsSshkeyFileEnv = "OBS_SSHKEYFILE"
ObsApiEnv = "OBS_API"
ObsWebEnv = "OBS_WEB"
DefaultGitPrj = "_ObsPrj"
PrjLinksFile = "links.json"

View File

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

@@ -29,6 +29,7 @@ import (
"path"
"path/filepath"
"slices"
"sync"
"time"
transport "github.com/go-openapi/runtime/client"
@@ -66,7 +67,16 @@ 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 {
ResetTimelineCache(org, repo string, idx int64)
GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error)
}
@@ -91,9 +101,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 +130,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 +199,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 +208,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 +485,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 +686,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,
@@ -659,7 +702,7 @@ func (gitea *GiteaTransport) CreatePullRequestIfNotExist(repo *models.Repository
WithHead(srcId),
gitea.transport.DefaultAuthentication,
); err == nil && pr.Payload.State == "open" {
return pr.Payload, nil
return pr.Payload, nil, false
}
pr, err := gitea.client.Repository.RepoCreatePullRequest(
@@ -673,10 +716,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 +806,91 @@ 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
func (gitea *GiteaTransport) ResetTimelineCache(org, repo string, idx int64) {
giteaTimelineCacheMutex.Lock()
defer giteaTimelineCacheMutex.Unlock()
prID := fmt.Sprintf("%s/%s!%d", org, repo, idx)
Cache, IsCached := giteaTimelineCache[prID]
if IsCached {
Cache.lastCheck = Cache.lastCheck.Add(-time.Hour)
giteaTimelineCache[prID] = Cache
}
}
// 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

@@ -1,10 +1,12 @@
package common
import (
"bytes"
"encoding/json"
"fmt"
"io"
"slices"
"strings"
"src.opensuse.org/autogits/common/gitea-generated/client/repository"
"src.opensuse.org/autogits/common/gitea-generated/models"
@@ -25,12 +27,15 @@ const ProjectFileKey = "_project"
type MaintainershipMap struct {
Data map[string][]string
IsDir bool
Config *AutogitConfig
FetchPackage func(string) ([]byte, error)
Raw []byte
}
func parseMaintainershipData(data []byte) (*MaintainershipMap, error) {
func ParseMaintainershipData(data []byte) (*MaintainershipMap, error) {
maintainers := &MaintainershipMap{
Data: make(map[string][]string),
Raw: data,
}
if err := json.Unmarshal(data, &maintainers.Data); err != nil {
return nil, err
@@ -39,7 +44,9 @@ func parseMaintainershipData(data []byte) (*MaintainershipMap, error) {
return maintainers, nil
}
func FetchProjectMaintainershipData(gitea GiteaMaintainershipReader, org, prjGit, branch string) (*MaintainershipMap, error) {
func FetchProjectMaintainershipData(gitea GiteaMaintainershipReader, config *AutogitConfig) (*MaintainershipMap, error) {
org, prjGit, branch := config.GetPrjGit()
data, _, err := gitea.FetchMaintainershipDirFile(org, prjGit, branch, ProjectFileKey)
dir := true
if err != nil || data == nil {
@@ -59,8 +66,9 @@ func FetchProjectMaintainershipData(gitea GiteaMaintainershipReader, org, prjGit
}
}
m, err := parseMaintainershipData(data)
m, err := ParseMaintainershipData(data)
if m != nil {
m.Config = config
m.IsDir = dir
m.FetchPackage = func(pkg string) ([]byte, error) {
data, _, err := gitea.FetchMaintainershipDirFile(org, prjGit, branch, pkg)
@@ -80,6 +88,8 @@ func (data *MaintainershipMap) ListProjectMaintainers(groups []*ReviewGroup) []s
return nil
}
m = slices.Clone(m)
// expands groups
for _, g := range groups {
m = g.ExpandMaintainers(m)
@@ -116,6 +126,7 @@ func (data *MaintainershipMap) ListPackageMaintainers(pkg string, groups []*Revi
}
}
}
pkgMaintainers = slices.Clone(pkgMaintainers)
prjMaintainers := data.ListProjectMaintainers(nil)
prjMaintainer:
@@ -149,7 +160,10 @@ func (data *MaintainershipMap) IsApproved(pkg string, reviews []*models.PullRevi
}
LogDebug("Looking for review by:", reviewers)
if slices.Contains(reviewers, submitter) {
slices.Sort(reviewers)
reviewers = slices.Compact(reviewers)
SubmitterIdxInReviewers := slices.Index(reviewers, submitter)
if SubmitterIdxInReviewers > -1 && (!data.Config.ReviewRequired || len(reviewers) == 1) {
LogDebug("Submitter is maintainer. Approving.")
return true
}
@@ -164,13 +178,135 @@ func (data *MaintainershipMap) IsApproved(pkg string, reviews []*models.PullRevi
return false
}
func (data *MaintainershipMap) modifyInplace(writer io.StringWriter) error {
var original map[string][]string
if err := json.Unmarshal(data.Raw, &original); err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(data.Raw))
_, err := dec.Token()
if err != nil {
return err
}
output := ""
lastPos := 0
modified := false
type entry struct {
key string
valStart int
valEnd int
}
var entries []entry
for dec.More() {
kToken, _ := dec.Token()
key := kToken.(string)
var raw json.RawMessage
dec.Decode(&raw)
valEnd := int(dec.InputOffset())
valStart := valEnd - len(raw)
entries = append(entries, entry{key, valStart, valEnd})
}
changed := make(map[string]bool)
for k, v := range data.Data {
if ov, ok := original[k]; !ok || !slices.Equal(v, ov) {
changed[k] = true
}
}
for k := range original {
if _, ok := data.Data[k]; !ok {
changed[k] = true
}
}
if len(changed) == 0 {
_, err = writer.WriteString(string(data.Raw))
return err
}
for _, e := range entries {
if v, ok := data.Data[e.key]; ok {
prefix := string(data.Raw[lastPos:e.valStart])
if modified && strings.TrimSpace(output) == "{" {
if commaIdx := strings.Index(prefix, ","); commaIdx != -1 {
if quoteIdx := strings.Index(prefix, "\""); quoteIdx == -1 || commaIdx < quoteIdx {
prefix = prefix[:commaIdx] + prefix[commaIdx+1:]
}
}
}
output += prefix
if changed[e.key] {
slices.Sort(v)
newVal, _ := json.Marshal(v)
output += string(newVal)
modified = true
} else {
output += string(data.Raw[e.valStart:e.valEnd])
}
} else {
// Deleted
modified = true
}
lastPos = e.valEnd
}
output += string(data.Raw[lastPos:])
// Handle additions (simplistic: at the end)
for k, v := range data.Data {
if _, ok := original[k]; !ok {
slices.Sort(v)
newVal, _ := json.Marshal(v)
keyStr, _ := json.Marshal(k)
// Insert before closing brace
if idx := strings.LastIndex(output, "}"); idx != -1 {
prefix := output[:idx]
suffix := output[idx:]
trimmedPrefix := strings.TrimRight(prefix, " \n\r\t")
if !strings.HasSuffix(trimmedPrefix, "{") && !strings.HasSuffix(trimmedPrefix, ",") {
// find the actual position of the last non-whitespace character in prefix
lastCharIdx := strings.LastIndexAny(prefix, "]}0123456789\"")
if lastCharIdx != -1 {
prefix = prefix[:lastCharIdx+1] + "," + prefix[lastCharIdx+1:]
}
}
insertion := fmt.Sprintf(" %s: %s", string(keyStr), string(newVal))
if !strings.HasSuffix(prefix, "\n") {
insertion = "\n" + insertion
}
output = prefix + insertion + "\n" + suffix
modified = true
}
}
}
if modified {
_, err := writer.WriteString(output)
return err
}
_, err = writer.WriteString(string(data.Raw))
return err
}
func (data *MaintainershipMap) WriteMaintainershipFile(writer io.StringWriter) error {
if data.IsDir {
return fmt.Errorf("Not implemented")
}
writer.WriteString("{\n")
if len(data.Raw) > 0 {
if err := data.modifyInplace(writer); err == nil {
return nil
}
}
// Fallback to full write
writer.WriteString("{\n")
if d, ok := data.Data[""]; ok {
eol := ","
if len(data.Data) == 1 {
@@ -181,17 +317,12 @@ func (data *MaintainershipMap) WriteMaintainershipFile(writer io.StringWriter) e
writer.WriteString(fmt.Sprintf(" \"\": %s%s\n", string(str), eol))
}
keys := make([]string, len(data.Data))
i := 0
keys := make([]string, 0, len(data.Data))
for pkg := range data.Data {
if pkg == "" {
continue
}
keys[i] = pkg
i++
}
if len(keys) >= i {
keys = slices.Delete(keys, i, len(keys))
keys = append(keys, pkg)
}
slices.Sort(keys)
for i, pkg := range keys {

View File

@@ -13,10 +13,10 @@ import (
)
func TestMaintainership(t *testing.T) {
config := common.AutogitConfig{
config := &common.AutogitConfig{
Branch: "bar",
Organization: "foo",
GitProjectName: common.DefaultGitPrj,
GitProjectName: common.DefaultGitPrj + "#bar",
}
packageTests := []struct {
@@ -141,7 +141,7 @@ func TestMaintainership(t *testing.T) {
notFoundError := repository.NewRepoGetContentsNotFound()
for _, test := range packageTests {
runTests := func(t *testing.T, mi common.GiteaMaintainershipReader) {
maintainers, err := common.FetchProjectMaintainershipData(mi, config.Organization, config.GitProjectName, config.Branch)
maintainers, err := common.FetchProjectMaintainershipData(mi, config)
if err != nil && !test.otherError {
if test.maintainersFileErr == nil {
t.Fatal("Unexpected error recieved", err)
@@ -208,6 +208,7 @@ func TestMaintainershipFileWrite(t *testing.T) {
name string
is_dir bool
maintainers map[string][]string
raw []byte
expected_output string
expected_error error
}{
@@ -231,6 +232,43 @@ func TestMaintainershipFileWrite(t *testing.T) {
},
expected_output: "{\n \"\": [\"one\",\"two\"],\n \"foo\": [\"byte\",\"four\"],\n \"pkg1\": []\n}\n",
},
{
name: "surgical modification",
maintainers: map[string][]string{
"": {"one", "two"},
"foo": {"byte", "four", "newone"},
"pkg1": {},
},
raw: []byte("{\n \"\": [\"one\",\"two\"],\n \"foo\": [\"byte\",\"four\"],\n \"pkg1\": []\n}\n"),
expected_output: "{\n \"\": [\"one\",\"two\"],\n \"foo\": [\"byte\",\"four\",\"newone\"],\n \"pkg1\": []\n}\n",
},
{
name: "no change",
maintainers: map[string][]string{
"": {"one", "two"},
"foo": {"byte", "four"},
"pkg1": {},
},
raw: []byte("{\n \"\": [\"one\",\"two\"],\n \"foo\": [\"byte\",\"four\"],\n \"pkg1\": []\n}\n"),
expected_output: "{\n \"\": [\"one\",\"two\"],\n \"foo\": [\"byte\",\"four\"],\n \"pkg1\": []\n}\n",
},
{
name: "surgical addition",
maintainers: map[string][]string{
"": {"one"},
"new": {"user"},
},
raw: []byte("{\n \"\": [ \"one\" ]\n}\n"),
expected_output: "{\n \"\": [ \"one\" ],\n \"new\": [\"user\"]\n}\n",
},
{
name: "surgical deletion",
maintainers: map[string][]string{
"": {"one"},
},
raw: []byte("{\n \"\": [\"one\"],\n \"old\": [\"user\"]\n}\n"),
expected_output: "{\n \"\": [\"one\"]\n}\n",
},
}
for _, test := range tests {
@@ -239,6 +277,7 @@ func TestMaintainershipFileWrite(t *testing.T) {
data := common.MaintainershipMap{
Data: test.maintainers,
IsDir: test.is_dir,
Raw: test.raw,
}
if err := data.WriteMaintainershipFile(&b); err != test.expected_error {
@@ -248,8 +287,134 @@ func TestMaintainershipFileWrite(t *testing.T) {
output := b.String()
if test.expected_output != output {
t.Fatal("unexpected output:", output, "Expecting:", test.expected_output)
t.Fatalf("unexpected output:\n%q\nExpecting:\n%q", output, test.expected_output)
}
})
}
}
func TestReviewRequired(t *testing.T) {
tests := []struct {
name string
maintainers []string
config *common.AutogitConfig
is_approved bool
}{
{
name: "ReviewRequired=false",
maintainers: []string{"maintainer1", "maintainer2"},
config: &common.AutogitConfig{ReviewRequired: false},
is_approved: true,
},
{
name: "ReviewRequired=true",
maintainers: []string{"maintainer1", "maintainer2"},
config: &common.AutogitConfig{ReviewRequired: true},
is_approved: false,
},
{
name: "ReviewRequired=true",
maintainers: []string{"maintainer1"},
config: &common.AutogitConfig{ReviewRequired: true},
is_approved: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
m := &common.MaintainershipMap{
Data: map[string][]string{"": test.maintainers},
}
m.Config = test.config
if approved := m.IsApproved("", nil, "maintainer1", nil); approved != test.is_approved {
t.Error("Expected m.IsApproved()->", test.is_approved, "but didn't get it")
}
})
}
}
func TestMaintainershipDataCorruption_PackageAppend(t *testing.T) {
// Test corruption when append happens (merging project maintainers)
// If backing array has capacity, append writes to it.
// We construct a slice with capacity > len to simulate this common scenario
backingArray := make([]string, 1, 10)
backingArray[0] = "@g1"
initialData := map[string][]string{
"pkg": backingArray, // len 1, cap 10
"": {"prjUser"},
}
m := &common.MaintainershipMap{
Data: initialData,
}
groups := []*common.ReviewGroup{
{
Name: "@g1",
Reviewers: []string{"u1"},
},
}
// ListPackageMaintainers("pkg", groups)
// 1. gets ["@g1"] (cap 10)
// 2. Appends "prjUser" -> ["@g1", "prjUser"] (in backing array)
// 3. Expands "@g1" -> "u1".
// Replace: ["u1", "prjUser"]
// Sort: ["prjUser", "u1"]
//
// The backing array is now ["prjUser", "u1", ...]
// The map entry "pkg" is still len 1.
// So it sees ["prjUser"].
list1 := m.ListPackageMaintainers("pkg", groups)
t.Logf("List1: %v", list1)
// ListPackageMaintainers("pkg", nil)
// Should be ["@g1", "prjUser"] (because prjUser is appended from project maintainers)
// But since backing array is corrupted:
// It sees ["prjUser"] (from map) + appends "prjUser" -> ["prjUser", "prjUser"].
list2 := m.ListPackageMaintainers("pkg", nil)
t.Logf("List2: %v", list2)
if !slices.Contains(list2, "@g1") {
t.Errorf("Corruption: '@g1' is missing from second call. Got %v", list2)
}
}
func TestMaintainershipDataCorruption_ProjectInPlace(t *testing.T) {
// Test corruption in ListProjectMaintainers when replacement fits in place
// e.g. replacing 1 group with 1 user.
initialData := map[string][]string{
"": {"@g1"},
}
m := &common.MaintainershipMap{
Data: initialData,
}
groups := []*common.ReviewGroup{
{
Name: "@g1",
Reviewers: []string{"u1"},
},
}
// First call with expansion
// Replaces "@g1" with "u1". Length stays 1. Modifies backing array in place.
list1 := m.ListProjectMaintainers(groups)
t.Logf("List1: %v", list1)
// Second call without expansion
// Should return ["@g1"]
list2 := m.ListProjectMaintainers(nil)
t.Logf("List2: %v", list2)
if !slices.Contains(list2, "@g1") {
t.Errorf("Corruption: '@g1' is missing from second call (Project). Got %v", list2)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -116,13 +116,18 @@ type Flags struct {
Contents string `xml:",innerxml"`
}
type ProjectLinkMeta struct {
Project string `xml:"project,attr"`
}
type ProjectMeta struct {
XMLName xml.Name `xml:"project"`
Name string `xml:"name,attr"`
Title string `xml:"title"`
Description string `xml:"description"`
Url string `xml:"url,omitempty"`
ScmSync string `xml:"scmsync"`
ScmSync string `xml:"scmsync,omitempty"`
Link []ProjectLinkMeta `xml:"link"`
Persons []PersonRepoMeta `xml:"person"`
Groups []GroupRepoMeta `xml:"group"`
Repositories []RepositoryMeta `xml:"repository"`
@@ -138,8 +143,8 @@ type ProjectMeta struct {
type PackageMeta struct {
XMLName xml.Name `xml:"package"`
Name string `xml:"name,attr"`
Project string `xml:"project,attr"`
ScmSync string `xml:"scmsync"`
Project string `xml:"project,attr,omitempty"`
ScmSync string `xml:"scmsync,omitempty"`
Persons []PersonRepoMeta `xml:"person"`
Groups []GroupRepoMeta `xml:"group"`

View File

@@ -23,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) {
@@ -33,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 {
@@ -63,7 +99,7 @@ func readPRData(gitea GiteaPRFetcher, pr *models.PullRequest, currentSet []*PRIn
var Timeline_RefIssueNotFound error = errors.New("RefIssue not found on the timeline")
func LastPrjGitRefOnTimeline(botUser string, gitea GiteaPRTimelineFetcher, org, repo string, num int64, config *AutogitConfig) (*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)
@@ -88,14 +124,19 @@ func LastPrjGitRefOnTimeline(botUser string, gitea GiteaPRTimelineFetcher, org,
}
pr, err := gitea.GetPullRequest(prjGitOrg, prjGitRepo, issue.Index)
switch err.(type) {
case *repository.RepoGetPullRequestNotFound: // deleted?
continue
default:
LogDebug("PrjGit RefIssue fetch error from timeline", issue.Index, err)
if err != nil {
switch err.(type) {
case *repository.RepoGetPullRequestNotFound: // deleted?
continue
default:
LogDebug("PrjGit RefIssue fetch error from timeline", issue.Index, err)
continue
}
}
if pr.Base.Ref != prjGitBranch {
LogDebug("found ref PR on timeline:", PRtoString(pr))
if pr.Base.Name != prjGitBranch {
LogDebug(" -> not matching:", pr.Base.Name, prjGitBranch)
continue
}
@@ -115,10 +156,12 @@ func LastPrjGitRefOnTimeline(botUser string, gitea GiteaPRTimelineFetcher, org,
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
gitea.ResetTimelineCache(org, repo, num)
prjGitOrg, prjGitRepo, _ := config.GetPrjGit()
if prjGitOrg == org && prjGitRepo == repo {
if pr, err = gitea.GetPullRequest(org, repo, num); err != nil {
@@ -141,6 +184,16 @@ func FetchPRSet(user string, gitea GiteaPRTimelineFetcher, org, repo string, num
return nil, err
}
for _, pr := range prs {
org, repo, idx := pr.PRComponents()
gitea.ResetTimelineCache(org, repo, idx)
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,
@@ -148,6 +201,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 &&
@@ -233,67 +292,150 @@ 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 := []string{}
if !rs.Config.ReviewRequired {
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 := []string{}
if !rs.Config.ReviewRequired {
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)
}
}
}
@@ -319,11 +461,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
@@ -339,11 +482,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)
@@ -365,6 +509,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
@@ -376,11 +523,12 @@ 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 gitea reaviews for PR:", err)
return false
}
r.RequestedReviewers = reviewers
is_manually_reviewed_ok = r.IsApproved()
LogDebug("PR to", pr.PR.Base.Repo.Name, "reviewed?", is_manually_reviewed_ok)
@@ -393,7 +541,7 @@ func (rs *PRSet) IsApproved(gitea GiteaPRChecker, maintainers MaintainershipData
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,283 +619,566 @@ func TestPR(t *testing.T) {
}
}
func TestPRAssignReviewers(t *testing.T) {
t.Skip("FAIL: unexpected calls, missing calls")
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: "Stale review is not done, re-request it",
config: common.AutogitConfig{
GitProjectName: "org/repo#main",
Organization: "org",
Branch: "main",
Reviewers: []string{"-user1", "user2"},
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",
},
pkgReviews: []*models.PullReview{
{
State: common.ReviewStateApproved,
User: &models.User{UserName: "user2"},
},
{
State: common.ReviewStatePending,
User: &models.User{UserName: "prjmaintainer"},
},
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"},
},
prjReviews: []*models.PullReview{
{
State: common.ReviewStateRequestChanges,
User: &models.User{UserName: "user1"},
Stale: true,
},
{
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"}}},
},
},
},
{
State: common.ReviewStateRequestReview,
Stale: true,
User: &models.User{UserName: "autogits_obs_staging_bot"},
Config: &common.AutogitConfig{
GitProjectName: "prg/repo#main",
Organization: "org",
Branch: "main",
Reviewers: []string{"-user1", "submitter", "*reviewer"},
},
BotUser: "bot",
},
expectedReviewerCall: [2][]string{{"user1", "autogits_obs_staging_bot"}, {"pkgmaintainer"}},
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 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: "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: "bot"},
Stale: true,
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: "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"}},
},
},
},
},
{
State: common.ReviewStateApproved,
User: &models.User{UserName: "user2"},
Config: &common.AutogitConfig{
GitProjectName: "prj/repo#main",
Organization: "org",
Branch: "main",
Reviewers: []string{"-user1", "submitter"},
},
{
State: common.ReviewStatePending,
User: &models.User{UserName: "prjmaintainer"},
BotUser: "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"}},
},
{
name: "Package maintainer submitter, AlwaysRequireReview=false",
prset: &common.PRSet{
PRs: []*common.PRInfo{
{
PR: &models.PullRequest{
User: &models.User{UserName: "pkgmaintainer"},
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{},
ReviewRequired: false,
},
},
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"},
maintainers: &common.MaintainershipMap{
Data: map[string][]string{
"pkg": {"pkgmaintainer", "pkgm1"},
},
},
expectedReviewerCall: [2][]string{{"user1", "autogits_obs_staging_bot"}, {"pkgmaintainer", "bot"}},
noAutoStaging: true,
expected_missing_reviewers: [][]string{
{},
},
},
{
name: "Package maintainer submitter, AlwaysRequireReview=true",
prset: &common.PRSet{
PRs: []*common.PRInfo{
{
PR: &models.PullRequest{
User: &models.User{UserName: "pkgmaintainer"},
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{},
ReviewRequired: true,
},
},
maintainers: &common.MaintainershipMap{
Data: map[string][]string{
"pkg": {"pkgmaintainer", "pkgm1"},
},
},
noAutoStaging: true,
expected_missing_reviewers: [][]string{
{"pkgm1"},
},
},
}
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)
})
}
}
@@ -978,7 +1261,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)
@@ -1037,7 +1320,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
@@ -130,7 +151,11 @@ func (r *PRReviews) IsApproved() bool {
func (r *PRReviews) MissingReviews() []string {
missing := []string{}
for _, reviewer := range r.reviewers {
if r == nil {
return missing
}
for _, reviewer := range r.RequestedReviewers {
if !r.IsReviewedBy(reviewer) {
missing = append(missing, reviewer)
}
@@ -138,45 +163,64 @@ func (r *PRReviews) MissingReviews() []string {
return missing
}
func (r *PRReviews) HasPendingReviewBy(reviewer string) bool {
if !slices.Contains(r.reviewers, reviewer) {
return false
func (r *PRReviews) FindReviewRequester(reviewer string) *models.TimelineComment {
if r == nil {
return nil
}
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 _, 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

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

View File

@@ -1,6 +1,7 @@
package common_test
import (
"reflect"
"testing"
"src.opensuse.org/autogits/common"
@@ -220,3 +221,87 @@ func TestRemovedBranchName(t *testing.T) {
})
}
}
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

@@ -58,6 +58,30 @@ sub ListPackages {
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]]";
@@ -65,6 +89,7 @@ sub Usage {
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);
@@ -139,7 +164,7 @@ if ( scalar @tomove > 0 ) {
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 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";
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";
}
}
@@ -167,6 +192,7 @@ for my $package ( sort(@packages) ) {
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) {
@@ -179,16 +205,27 @@ for my $package ( sort(@packages) ) {
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 system(
"cd $package; osc ls -v openSUSE:Factory $package | awk '{print \$1 FS \$7}' | grep -v -F '_scmsync.obsinfo\nbuild.specials.obscpio' | md5sum -c --quiet"
) == 0
and system("bash -c \"diff <(ls -1 $package | sort) <(osc ls openSUSE:Factory $package | grep -v -F '_scmsync.obsinfo\nbuild.specials.obscpio' | sort)\"") == 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;
}
$c = $commit;
$match = 1;
last;
}
}

View File

@@ -1,16 +1,24 @@
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:mailman
devel:languages:python:pytest
devel:openSUSE:Factory
network:chromium
network:dhcp
network:im:whatsapp
network:messaging:xmpp
science:HPC
server:dns
systemsmanagement:cockpit
X11:lxde

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,17 +216,17 @@ 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)
}
@@ -233,30 +237,30 @@ func ProcessPR(pr *models.PullRequest) error {
// 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)
}
})
}

11
integration/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM opensuse/tumbleweed
ENV container=podman
ENV LANG=en_US.UTF-8
RUN zypper -vvvn install podman podman-compose vim make python3-pytest python3-requests python3-pytest-dependency
COPY . /opt/project/
WORKDIR /opt/project/integration

76
integration/Makefile Normal file
View File

@@ -0,0 +1,76 @@
# We want to be able to test in two **modes**:
# A. bots are used from official packages as defined in */Dockerfile.package
# B. bots are just picked up from binaries that are placed in corresponding parent directory.
# The topology is defined in podman-compose file and can be spawned in two ways:
# 1. Privileged container (needs no additional dependancies)
# 2. podman-compose on a local machine (needs dependencies as defined in the Dockerfile)
# Typical workflow:
# A1: - run 'make test_package'
# B1: - run 'make test_local' (make sure that the go binaries in parent folder are built)
# A2:
# 1. 'make build_package' - prepares images (recommended, otherwise there might be surprises if image fails to build during `make up`)
# 2. 'make up' - spawns podman-compose
# 3. 'pytest -v tests/*' - run tests
# 4. 'make down' - once the containers are not needed
# B2: (make sure the go binaries in the parent folder are built)
# 4. 'make build_local' - prepared images (recommended, otherwise there might be surprises if image fails to build during `make up`)
# 5. 'make up' - spawns podman-compose
# 6. 'pytest -v tests/*' - run tests
# 7. 'make down' - once the containers are not needed
AUTO_DETECT_MODE := $(shell if test -e ../workflow-pr/workflow-pr; then echo .local; else echo .package; fi)
# try to detect mode B1, otherwise mode A1
test: GIWTF_IMAGE_SUFFIX=$(AUTO_DETECT_MODE)
test: build_container test_container
# mode A1
test_package: GIWTF_IMAGE_SUFFIX=.package
test_package: build_container test_container
# mode B1
test_local: GIWTF_IMAGE_SUFFIX=.local
test_local: build_container test_container
MODULES := gitea-events-rabbitmq-publisher obs-staging-bot workflow-pr
# Prepare topology 1
build_container:
podman build ../ -f integration/Dockerfile -t autogits_integration
# Run tests in topology 1
test_container:
podman run --rm --privileged -t --network integration_gitea-network -e GIWTF_IMAGE_SUFFIX=$(GIWTF_IMAGE_SUFFIX) autogits_integration /usr/bin/bash -c "make build && make up && sleep 25 && pytest -v tests/*"
build_local: AUTO_DETECT_MODE=.local
build_local: build
build_package: AUTO_DETECT_MODE=.package
build_package: build
# parse all service images from podman-compose and build them (topology 2)
build:
podman pull docker.io/library/rabbitmq:3.13.7-management
for i in $$(grep -A 1000 services: podman-compose.yml | grep -oE '^ [^: ]+'); do GIWTF_IMAGE_SUFFIX=$(AUTO_DETECT_MODE) podman-compose build $$i || exit 1; done
# this will spawn prebuilt containers (topology 2)
up:
podman-compose up -d
# tear down (topology 2)
down:
podman-compose down
# mode A
up-bots-package:
GIWTF_IMAGE_SUFFIX=.package podman-compose up -d
# mode B
up-bots-local:
GIWTF_IMAGE_SUFFIX=.local podman-compose up -d

1
integration/clean.sh Executable file
View File

@@ -0,0 +1 @@
sudo rm -rf gitea-data/ gitea-logs/ rabbitmq-data/ workflow-pr-repos/

View File

@@ -0,0 +1 @@
Dockerfile.package

View File

@@ -0,0 +1,15 @@
FROM registry.suse.com/bci/bci-base:15.7
# Add the custom CA to the trust store
COPY integration/rabbitmq-config/certs/cert.pem /usr/share/pki/trust/anchors/gitea-rabbitmq-ca.crt
RUN update-ca-certificates
RUN zypper -n in which binutils
# Copy the pre-built binary into the container
# The user will build this and place it in the same directory as this Dockerfile
COPY gitea-events-rabbitmq-publisher/gitea-events-rabbitmq-publisher /usr/local/bin/
COPY integration/gitea-events-rabbitmq-publisher/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -0,0 +1,15 @@
FROM registry.suse.com/bci/bci-base:15.7
# Add the custom CA to the trust store
COPY integration/rabbitmq-config/certs/cert.pem /usr/share/pki/trust/anchors/gitea-rabbitmq-ca.crt
RUN update-ca-certificates
RUN zypper ar -f http://download.opensuse.org/repositories/devel:/Factory:/git-workflow/15.7/devel:Factory:git-workflow.repo
RUN zypper --gpg-auto-import-keys ref
RUN zypper -n in git-core curl autogits-gitea-events-rabbitmq-publisher binutils
COPY integration/gitea-events-rabbitmq-publisher/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -e
exe=$(which gitea-events-rabbitmq-publisher 2>/dev/null) || :
exe=${exe:-/usr/local/bin/gitea-events-rabbitmq-publisher}
package=$(rpm -qa | grep autogits-gitea-events-rabbitmq-publisher) || :
echo "!!!!!!!!!!!!!!!! using binary $exe; installed package: $package"
which strings > /dev/null 2>&1 && strings "$exe" | grep -A 2 vcs.revision= | head -4 || :
echo "RABBITMQ_HOST: $RABBITMQ_HOST"
exec $exe "$@"

View File

@@ -0,0 +1,25 @@
FROM registry.suse.com/bci/bci-base:15.7
RUN zypper ar --repo https://download.opensuse.org/repositories/devel:/Factory:/git-workflow/15.7/devel:Factory:git-workflow.repo \
&& zypper -n --gpg-auto-import-keys refresh
RUN zypper -n install \
git \
sqlite3 \
curl \
gawk \
openssh \
jq \
devel_Factory_git-workflow:gitea \
&& rm -rf /var/cache/zypp/*
# Copy the minimal set of required files from the local 'container-files' directory
COPY container-files/ /
RUN chmod -R 777 /etc/gitea/conf
# Make the setup and entrypoint scripts executable
RUN chmod +x /opt/setup/setup-gitea.sh && chmod +x /opt/setup/entrypoint.sh && chmod +x /opt/setup/setup-webhook.sh && chmod +x /opt/setup/setup-dummy-data.sh
# Use the new entrypoint script to start the container
ENTRYPOINT ["/opt/setup/entrypoint.sh"]

View File

@@ -0,0 +1,42 @@
WORK_PATH = /var/lib/gitea
[server]
CERT_FILE = /etc/gitea/https/cert.pem
KEY_FILE = /etc/gitea/https/key.pem
STATIC_ROOT_PATH = /usr/share/gitea
APP_DATA_PATH = /var/lib/gitea/data
PPROF_DATA_PATH = /var/lib/gitea/data/tmp/pprof
PROTOCOL = http
DOMAIN = gitea-test
SSH_DOMAIN = gitea-test
ROOT_URL = http://gitea-test:3000/
HTTP_PORT = 3000
DISABLE_SSH = false
START_SSH_SERVER = true
SSH_PORT = 3022
LFS_START_SERVER = true
[lfs]
PATH = /var/lib/gitea/data/lfs
[database]
DB_TYPE = sqlite3
PATH = /var/lib/gitea/data/gitea.db
[security]
INSTALL_LOCK = true
[oauth2]
ENABLED = false
[log]
ROOT_PATH = /var/log/gitea
MODE = console, file
; Either "Trace", "Debug", "Info", "Warn", "Error" or "None", default is "Info"
LEVEL = Debug
[service]
ENABLE_BASIC_AUTHENTICATION = true
[webhook]
ALLOWED_HOST_LIST = gitea-publisher

View File

@@ -0,0 +1,19 @@
#!/bin/bash
set -e
# Run setup to ensure permissions, migrations, and the admin user are ready.
# The setup script is now idempotent.
/opt/setup/setup-gitea.sh
# Start the webhook setup script in the background.
# It will wait for the main Gitea process to be ready before creating the webhook.
/opt/setup/setup-webhook.sh &
echo "Starting Gitea..."
# The original systemd service ran as user 'gitea' and group 'gitea'
# with a working directory of '/var/lib/gitea'.
# We will switch to that user and run the web command.
# Using exec means Gitea will become PID 1, allowing it to receive signals correctly.
cd /var/lib/gitea
exec su -s /bin/bash gitea -c "/usr/bin/gitea web --config /etc/gitea/conf/app.ini"

View File

@@ -0,0 +1,2 @@
#!/bin/bash
# This script is now empty as dummy data setup is handled by pytest fixtures.

View File

@@ -0,0 +1,100 @@
#!/bin/bash
set -x
set -e
# Set ownership on the volume mounts. This allows the 'gitea' user to write to them.
# We use -R to ensure all subdirectories (like /var/lib/gitea/data) are covered.
chown -R gitea:gitea /var/lib/gitea /var/log/gitea
# Set ownership on the config directory.
chown -R gitea:gitea /etc/gitea
# Run database migrations to initialize the sqlite3 db based on app.ini.
su -s /bin/bash gitea -c 'gitea migrate'
# Create a default admin user if it doesn't exist
if ! su -s /bin/bash gitea -c 'gitea admin user list' | awk 'NR>1 && $2 == "admin" {found=1} END {exit !found}'; then
echo "Creating admin user..."
su -s /bin/bash gitea -c 'gitea admin user create --username admin --password opensuse --email admin@example.com --must-change-password=false --admin'
else
echo "Admin user already exists."
fi
# Generate an access token for the admin user
ADMIN_TOKEN_FILE="/var/lib/gitea/admin.token"
if [ -f "$ADMIN_TOKEN_FILE" ]; then
echo "Admin token already exists at $ADMIN_TOKEN_FILE."
else
echo "Generating admin token..."
ADMIN_TOKEN=$(su -s /bin/bash gitea -c "gitea admin user generate-access-token -raw -u admin -t admin-token")
if [ -n "$ADMIN_TOKEN" ]; then
printf "%s" "$ADMIN_TOKEN" > "$ADMIN_TOKEN_FILE"
chmod 777 "$ADMIN_TOKEN_FILE"
chown gitea:gitea "$ADMIN_TOKEN_FILE"
echo "Admin token generated and saved to $ADMIN_TOKEN_FILE."
else
echo "Failed to generate admin token."
fi
fi
# Generate SSH key for the admin user if it doesn't exist
SSH_KEY_DIR="/var/lib/gitea/ssh-keys"
mkdir -p "$SSH_KEY_DIR"
if [ ! -f "$SSH_KEY_DIR/id_ed25519" ]; then
echo "Generating SSH key for admin user..."
ssh-keygen -t ed25519 -N "" -f "$SSH_KEY_DIR/id_ed25519"
chown -R gitea:gitea "$SSH_KEY_DIR"
chmod 700 "$SSH_KEY_DIR"
chmod 600 "$SSH_KEY_DIR/id_ed25519"
chmod 644 "$SSH_KEY_DIR/id_ed25519.pub"
fi
# Create a autogits_obs_staging_bot user if it doesn't exist
if ! su -s /bin/bash gitea -c 'gitea admin user list' | awk 'NR>1 && $2 == "autogits_obs_staging_bot" {found=1} END {exit !found}'; then
echo "Creating autogits_obs_staging_bot user..."
su -s /bin/bash gitea -c 'gitea admin user create --username autogits_obs_staging_bot --password opensuse --email autogits_obs_staging_bot@example.com --must-change-password=false'
else
echo "autogits_obs_staging_bot user already exists."
fi
# Generate an access token for the autogits_obs_staging_bot user
BOT_TOKEN_FILE="/var/lib/gitea/autogits_obs_staging_bot.token"
if [ -f "$BOT_TOKEN_FILE" ]; then
echo "autogits_obs_staging_bot token already exists at $BOT_TOKEN_FILE."
else
echo "Generating autogits_obs_staging_bot token..."
BOT_TOKEN=$(su -s /bin/bash gitea -c "gitea admin user generate-access-token -raw -u autogits_obs_staging_bot -t autogits_obs_staging_bot-token")
if [ -n "$BOT_TOKEN" ]; then
printf "%s" "$BOT_TOKEN" > "$BOT_TOKEN_FILE"
chmod 666 "$BOT_TOKEN_FILE"
chown gitea:gitea "$BOT_TOKEN_FILE"
echo "autogits_obs_staging_bot token generated and saved to $BOT_TOKEN_FILE."
else
echo "Failed to generate autogits_obs_staging_bot token."
fi
fi
# Create a workflow-pr user if it doesn't exist
if ! su -s /bin/bash gitea -c 'gitea admin user list' | awk 'NR>1 && $2 == "workflow-pr" {found=1} END {exit !found}'; then
echo "Creating workflow-pr user..."
su -s /bin/bash gitea -c 'gitea admin user create --username workflow-pr --password opensuse --email workflow-pr@example.com --must-change-password=false'
else
echo "workflow-pr user already exists."
fi
# Generate an access token for the workflow-pr user
BOT_TOKEN_FILE="/var/lib/gitea/workflow-pr.token"
if [ -f "$BOT_TOKEN_FILE" ]; then
echo "workflow-pr token already exists at $BOT_TOKEN_FILE."
else
echo "Generating workflow-pr token..."
BOT_TOKEN=$(su -s /bin/bash gitea -c "gitea admin user generate-access-token -raw -u workflow-pr -t workflow-pr-token")
if [ -n "$BOT_TOKEN" ]; then
printf "%s" "$BOT_TOKEN" > "$BOT_TOKEN_FILE"
chmod 666 "$BOT_TOKEN_FILE"
chown gitea:gitea "$BOT_TOKEN_FILE"
echo "workflow-pr token generated and saved to $BOT_TOKEN_FILE."
else
echo "Failed to generate workflow-pr token."
fi
fi

View File

@@ -0,0 +1,92 @@
#!/bin/bash
set -e
GITEA_URL="http://localhost:3000"
WEBHOOK_URL="http://gitea-publisher:8002/rabbitmq-forwarder"
TOKEN_NAME="webhook-creator"
echo "Webhook setup script started in background."
# Wait 10s for the main Gitea process to start
sleep 10
# Wait for Gitea API to be ready
echo "Waiting for Gitea API at $GITEA_URL..."
while ! curl -s -f "$GITEA_URL/api/v1/version" > /dev/null; do
echo "Gitea API not up yet, waiting 5s..."
sleep 5
done
echo "Gitea API is up."
# The `gitea admin` command needs to be run as the gitea user.
# The -raw flag gives us the token directly.
echo "Generating or retrieving admin token..."
TOKEN_FILE="/var/lib/gitea/admin.token"
if [ -f "$TOKEN_FILE" ]; then
TOKEN=$(cat "$TOKEN_FILE" | tr -d '\n\r ')
echo "Admin token loaded from $TOKEN_FILE."
else
TOKEN=$(su -s /bin/bash gitea -c "gitea admin user generate-access-token -raw -u admin -t $TOKEN_NAME")
if [ -n "$TOKEN" ]; then
printf "%s" "$TOKEN" > "$TOKEN_FILE"
chmod 666 "$TOKEN_FILE"
chown gitea:gitea "$TOKEN_FILE"
echo "Admin token generated and saved to $TOKEN_FILE."
fi
fi
if [ -z "$TOKEN" ]; then
echo "Failed to generate or retrieve admin token. This might be because the token already exists in Gitea but not in $TOKEN_FILE. Exiting."
exit 1
fi
# Run the dummy data setup script
/opt/setup/setup-dummy-data.sh "$GITEA_URL" "$TOKEN"
# Add SSH key via API
PUB_KEY_FILE="/var/lib/gitea/ssh-keys/id_ed25519.pub"
if [ -f "$PUB_KEY_FILE" ]; then
echo "Checking for existing SSH key 'bot-key'..."
KEYS_URL="$GITEA_URL/api/v1/admin/users/workflow-pr/keys"
EXISTING_KEYS=$(curl -s -X GET -H "Authorization: token $TOKEN" "$KEYS_URL")
if ! echo "$EXISTING_KEYS" | grep -q "\"title\":\"bot-key\""; then
echo "Registering SSH key 'bot-key' via API..."
KEY_CONTENT=$(cat "$PUB_KEY_FILE")
curl -s -X POST "$KEYS_URL" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"key\": \"$KEY_CONTENT\",
\"read_only\": false,
\"title\": \"bot-key\"
}"
echo -e "\nSSH key registered."
else
echo "SSH key 'bot-key' already registered."
fi
fi
# Check if the webhook already exists
echo "Checking for existing system webhook..."
DB_PATH="/var/lib/gitea/data/gitea.db"
EXISTS=$(su -s /bin/bash gitea -c "sqlite3 '$DB_PATH' \"SELECT 1 FROM webhook WHERE url = '$WEBHOOK_URL' AND is_system_webhook = 1 LIMIT 1;\"")
if [ "$EXISTS" = "1" ]; then
echo "System webhook for $WEBHOOK_URL already exists. Exiting."
exit 0
fi
echo "Creating Gitea system webhook for $WEBHOOK_URL via direct database INSERT..."
# The events JSON requires escaped double quotes for the sqlite3 command.
EVENTS_JSON='{\"push_only\":false,\"send_everything\":true,\"choose_events\":false,\"branch_filter\":\"*\",\"events\":{\"create\":false,\"delete\":false,\"fork\":false,\"issue_assign\":false,\"issue_comment\":false,\"issue_label\":false,\"issue_milestone\":false,\"issues\":false,\"package\":false,\"pull_request\":false,\"pull_request_assign\":false,\"pull_request_comment\":false,\"pull_request_label\":false,\"pull_request_milestone\":false,\"pull_request_review\":false,\"pull_request_review_request\":false,\"pull_request_sync\":false,\"push\":false,\"release\":false,\"repository\":false,\"status\":false,\"wiki\":false,\"workflow_job\":false,\"workflow_run\":false}}'
NOW_UNIX=$(date +%s)
INSERT_CMD="INSERT INTO webhook (repo_id, owner_id, is_system_webhook, url, http_method, content_type, events, is_active, type, meta, created_unix, updated_unix) VALUES (0, 0, 1, '$WEBHOOK_URL', 'POST', 1, '$EVENTS_JSON', 1, 'gitea', '', $NOW_UNIX, $NOW_UNIX);"
su -s /bin/bash gitea -c "sqlite3 '$DB_PATH' \"$INSERT_CMD\""
echo "System webhook created successfully."
exit 0

View File

@@ -0,0 +1,14 @@
# Use a base Python image
FROM registry.suse.com/bci/python:3.11
# Set the working directory
WORKDIR /app
# Copy the server script
COPY server.py .
# Expose the port the server will run on
EXPOSE 8080
# Command to run the server
CMD ["python3", "-u", "server.py"]

View File

@@ -0,0 +1,18 @@
<project name="openSUSE:Leap:16.0:PullRequest">
<title>Leap 16.0 PullRequest area</title>
<description>Base project to define the pull request builds</description>
<person userid="autogits_obs_staging_bot" role="maintainer"/>
<person userid="maxlin_factory" role="maintainer"/>
<group groupid="maintenance-opensuse.org" role="maintainer"/>
<debuginfo>
<enable/>
</debuginfo>
<repository name="standard">
<path project="openSUSE:Leap:16.0" repository="standard"/>
<arch>x86_64</arch>
<arch>i586</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
</project>

View File

@@ -0,0 +1,59 @@
<project name="openSUSE:Leap:16.0">
<title>openSUSE Leap 16.0 based on SLFO</title>
<description>Leap 16.0 based on SLES 16.0 (specifically SLFO:1.2)</description>
<link project="openSUSE:Backports:SLE-16.0"/>
<scmsync>http://gitea-test:3000/products/SLFO#main</scmsync>
<person userid="dimstar_suse" role="maintainer"/>
<person userid="lkocman-factory" role="maintainer"/>
<person userid="maxlin_factory" role="maintainer"/>
<person userid="factory-auto" role="reviewer"/>
<person userid="licensedigger" role="reviewer"/>
<group groupid="autobuild-team" role="maintainer"/>
<group groupid="factory-maintainers" role="maintainer"/>
<group groupid="maintenance-opensuse.org" role="maintainer"/>
<group groupid="factory-staging" role="reviewer"/>
<build>
<disable repository="ports"/>
</build>
<debuginfo>
<enable/>
</debuginfo>
<repository name="standard" rebuild="local">
<path project="openSUSE:Backports:SLE-16.0" repository="standard"/>
<path project="SUSE:SLFO:1.2" repository="standard"/>
<arch>local</arch>
<arch>i586</arch>
<arch>x86_64</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
<repository name="product">
<releasetarget project="openSUSE:Leap:16.0:ToTest" repository="product" trigger="manual"/>
<path project="openSUSE:Leap:16.0:NonFree" repository="standard"/>
<path project="openSUSE:Leap:16.0" repository="images"/>
<path project="openSUSE:Leap:16.0" repository="standard"/>
<path project="openSUSE:Backports:SLE-16.0" repository="standard"/>
<path project="SUSE:SLFO:1.2" repository="standard"/>
<arch>local</arch>
<arch>i586</arch>
<arch>x86_64</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
<repository name="ports">
<arch>armv7l</arch>
</repository>
<repository name="images">
<releasetarget project="openSUSE:Leap:16.0:ToTest" repository="images" trigger="manual"/>
<path project="openSUSE:Leap:16.0" repository="standard"/>
<path project="openSUSE:Backports:SLE-16.0" repository="standard"/>
<path project="SUSE:SLFO:1.2" repository="standard"/>
<arch>i586</arch>
<arch>x86_64</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
</project>

View File

@@ -0,0 +1,140 @@
import http.server
import socketserver
import os
import logging
import signal
import sys
import threading
import fnmatch
PORT = 8080
RESPONSE_DIR = "/app/responses"
STATE_DIR = "/tmp/mock_obs_state"
class MockOBSHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
logging.info(f"GET request for: {self.path}")
path_without_query = self.path.split('?')[0]
# Check for state stored by a PUT request first
sanitized_put_path = 'PUT' + path_without_query.replace('/', '_')
state_file_path = os.path.join(STATE_DIR, sanitized_put_path)
if os.path.exists(state_file_path):
logging.info(f"Found stored PUT state for {self.path} at {state_file_path}")
self.send_response(200)
self.send_header("Content-type", "application/xml")
file_size = os.path.getsize(state_file_path)
self.send_header("Content-Length", str(file_size))
self.end_headers()
with open(state_file_path, 'rb') as f:
self.wfile.write(f.read())
return
# If no PUT state file, fall back to the glob/exact match logic
self.handle_request('GET')
def do_PUT(self):
logging.info(f"PUT request for: {self.path}")
logging.info(f"Headers: {self.headers}")
path_without_query = self.path.split('?')[0]
body = b''
if self.headers.get('Transfer-Encoding', '').lower() == 'chunked':
logging.info("Chunked transfer encoding detected")
while True:
line = self.rfile.readline().strip()
if not line:
break
chunk_length = int(line, 16)
if chunk_length == 0:
self.rfile.readline()
break
body += self.rfile.read(chunk_length)
self.rfile.read(2) # Read the trailing CRLF
else:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length)
logging.info(f"Body: {body.decode('utf-8')}")
sanitized_path = 'PUT' + path_without_query.replace('/', '_')
state_file_path = os.path.join(STATE_DIR, sanitized_path)
logging.info(f"Saving state for {self.path} to {state_file_path}")
os.makedirs(os.path.dirname(state_file_path), exist_ok=True)
with open(state_file_path, 'wb') as f:
f.write(body)
self.send_response(200)
self.send_header("Content-type", "text/plain")
response_body = b"OK"
self.send_header("Content-Length", str(len(response_body)))
self.end_headers()
self.wfile.write(response_body)
def do_POST(self):
logging.info(f"POST request for: {self.path}")
self.handle_request('POST')
def do_DELETE(self):
logging.info(f"DELETE request for: {self.path}")
self.handle_request('DELETE')
def handle_request(self, method):
path_without_query = self.path.split('?')[0]
sanitized_request_path = method + path_without_query.replace('/', '_')
logging.info(f"Handling request, looking for match for: {sanitized_request_path}")
response_file = None
# Check for glob match first
if os.path.exists(RESPONSE_DIR):
for filename in os.listdir(RESPONSE_DIR):
if fnmatch.fnmatch(sanitized_request_path, filename):
response_file = os.path.join(RESPONSE_DIR, filename)
logging.info(f"Found matching response file (glob): {response_file}")
break
# Fallback to exact match if no glob match
if response_file is None:
exact_file = os.path.join(RESPONSE_DIR, sanitized_request_path)
if os.path.exists(exact_file):
response_file = exact_file
logging.info(f"Found matching response file (exact): {response_file}")
if response_file:
logging.info(f"Serving content from {response_file}")
self.send_response(200)
self.send_header("Content-type", "application/xml")
file_size = os.path.getsize(response_file)
self.send_header("Content-Length", str(file_size))
self.end_headers()
with open(response_file, 'rb') as f:
self.wfile.write(f.read())
else:
logging.info(f"Response file not found for {sanitized_request_path}. Sending 404.")
self.send_response(404)
self.send_header("Content-type", "text/plain")
body = f"Mock response not found for {sanitized_request_path}".encode('utf-8')
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
if not os.path.exists(STATE_DIR):
logging.info(f"Creating state directory: {STATE_DIR}")
os.makedirs(STATE_DIR)
if not os.path.exists(RESPONSE_DIR):
os.makedirs(RESPONSE_DIR)
with socketserver.TCPServer(("", PORT), MockOBSHandler) as httpd:
logging.info(f"Serving mock OBS API on port {PORT}")
def graceful_shutdown(sig, frame):
logging.info("Received SIGTERM, shutting down gracefully...")
threading.Thread(target=httpd.shutdown).start()
signal.signal(signal.SIGTERM, graceful_shutdown)
httpd.serve_forever()
logging.info("Server has shut down.")

View File

@@ -0,0 +1 @@
./Dockerfile.package

View File

@@ -0,0 +1,18 @@
# Use a base Python image
FROM registry.suse.com/bci/bci-base:15.7
# Install any necessary dependencies for the bot
# e.g., git, curl, etc.
RUN zypper -n in git-core curl binutils
# Copy the bot binary and its entrypoint script
COPY obs-staging-bot/obs-staging-bot /usr/local/bin/obs-staging-bot
COPY integration/obs-staging-bot/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Create a non-root user to run the bot
RUN useradd -m -u 1001 bot
USER 1001
# Set the entrypoint
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -0,0 +1,19 @@
# Use a base Python image
FROM registry.suse.com/bci/bci-base:15.7
RUN zypper ar -f http://download.opensuse.org/repositories/devel:/Factory:/git-workflow/15.7/devel:Factory:git-workflow.repo
RUN zypper --gpg-auto-import-keys ref
# Install any necessary dependencies for the bot
# e.g., git, curl, etc.
RUN zypper -n in git-core curl autogits-obs-staging-bot binutils
COPY integration/obs-staging-bot/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Create a non-root user to run the bot
RUN useradd -m -u 1001 bot
USER 1001
# Set the entrypoint
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -0,0 +1,28 @@
#!/bin/sh
set -e
# This script waits for the Gitea admin token to be created,
# exports it as an environment variable, and then executes the main container command.
TOKEN_FILE="/gitea-data/autogits_obs_staging_bot.token"
echo "OBS Staging Bot: Waiting for Gitea autogits_obs_staging_bot token at $TOKEN_FILE..."
while [ ! -s "$TOKEN_FILE" ]; do
sleep 2
done
export GITEA_TOKEN=$(cat "$TOKEN_FILE" | tr -d '\n\r ')
echo "OBS Staging Bot: GITEA_TOKEN exported."
# Execute the bot as the current user (root), using 'env' to pass required variables.
echo "OBS Staging Bot: Executing bot..."
exe=$(which obs-staging-bot)
exe=${exe:-/usr/local/bin/obs-staging-bot}
package=$(rpm -qa | grep autogits-obs-staging-bot) || :
echo "!!!!!!!!!!!!!!!! using binary $exe; installed package: $package"
which strings > /dev/null 2>&1 && strings "$exe" | grep -A 2 vcs.revision= | head -4 || :
exec $exe "$@"

View File

@@ -0,0 +1,136 @@
version: "3.8"
networks:
gitea-network:
driver: bridge
services:
gitea:
build: ./gitea
container_name: gitea-test
environment:
- GITEA_WORK_DIR=/var/lib/gitea
networks:
- gitea-network
ports:
# Map the HTTP and SSH ports defined in your app.ini
- "3000:3000"
- "3022:3022"
volumes:
# Persist Gitea's data (repositories, sqlite db, etc.) to a local directory
# The :z flag allows sharing between containers
- ./gitea-data:/var/lib/gitea:z
# Persist Gitea's logs to a local directory
- ./gitea-logs:/var/log/gitea:Z
restart: unless-stopped
rabbitmq:
image: rabbitmq:3.13.7-management
container_name: rabbitmq-test
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_running", "-q"]
interval: 30s
timeout: 30s
retries: 3
networks:
- gitea-network
ports:
# AMQP protocol port with TLS
- "5671:5671"
# HTTP management UI
- "15672:15672"
volumes:
# Persist RabbitMQ data
- ./rabbitmq-data:/var/lib/rabbitmq:Z
# Mount TLS certs
- ./rabbitmq-config/certs:/etc/rabbitmq/certs:Z
# Mount rabbitmq config
- ./rabbitmq-config/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:Z
# Mount exchange definitions
- ./rabbitmq-config/definitions.json:/etc/rabbitmq/definitions.json:Z
restart: unless-stopped
gitea-publisher:
build:
context: ..
dockerfile: integration/gitea-events-rabbitmq-publisher/Dockerfile${GIWTF_IMAGE_SUFFIX}
container_name: gitea-publisher
networks:
- gitea-network
depends_on:
gitea:
condition: service_started
rabbitmq:
condition: service_healthy
environment:
- RABBITMQ_HOST=rabbitmq-test
- RABBITMQ_USERNAME=gitea
- RABBITMQ_PASSWORD=gitea
- SSL_CERT_FILE=/usr/share/pki/trust/anchors/gitea-rabbitmq-ca.crt
command: [ "-listen", "0.0.0.0:8002", "-topic-domain", "suse", "-debug" ]
restart: unless-stopped
workflow-pr:
build:
context: ..
dockerfile: integration/workflow-pr/Dockerfile${GIWTF_IMAGE_SUFFIX}
container_name: workflow-pr
networks:
- gitea-network
depends_on:
gitea:
condition: service_started
rabbitmq:
condition: service_healthy
environment:
- AMQP_USERNAME=gitea
- AMQP_PASSWORD=gitea
- SSL_CERT_FILE=/usr/share/pki/trust/anchors/gitea-rabbitmq-ca.crt
volumes:
- ./gitea-data:/var/lib/gitea:ro,z
- ./workflow-pr/workflow-pr.json:/etc/workflow-pr.json:ro,z
- ./workflow-pr-repos:/var/lib/workflow-pr/repos:Z
command: [
"-check-on-start",
"-debug",
"-gitea-url", "http://gitea-test:3000",
"-url", "amqps://rabbitmq-test:5671",
"-config", "/etc/workflow-pr.json",
"-repo-path", "/var/lib/workflow-pr/repos"
]
restart: unless-stopped
mock-obs:
build: ./mock-obs
container_name: mock-obs
networks:
- gitea-network
ports:
- "8080:8080"
volumes:
- ./mock-obs/responses:/app/responses:z # Use :z for shared SELinux label
restart: unless-stopped
obs-staging-bot:
build:
context: ..
dockerfile: integration/obs-staging-bot/Dockerfile${GIWTF_IMAGE_SUFFIX}
container_name: obs-staging-bot
networks:
- gitea-network
depends_on:
gitea:
condition: service_started
mock-obs:
condition: service_started
environment:
- OBS_USER=mock
- OBS_PASSWORD=mock-long-password
volumes:
- ./gitea-data:/gitea-data:ro,z
command:
- "-debug"
- "-gitea-url=http://gitea-test:3000"
- "-obs=http://mock-obs:8080"
- "-obs-web=http://mock-obs:8080"
restart: unless-stopped

View File

@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFKzCCAxOgAwIBAgIUJsg/r0ZyIVxtAkrlZKOr4LvYEvMwDQYJKoZIhvcNAQEL
BQAwGDEWMBQGA1UEAwwNcmFiYml0bXEtdGVzdDAeFw0yNjAxMjQxMjQyMjNaFw0z
NjAxMjIxMjQyMjNaMBgxFjAUBgNVBAMMDXJhYmJpdG1xLXRlc3QwggIiMA0GCSqG
SIb3DQEBAQUAA4ICDwAwggIKAoICAQC9OjTq4DgqVo0mRpS8DGRR6SFrSpb2bqnl
YI7xSI3y67i/oP4weiZSawk2+euxhsN4FfOlsAgvpg4WyRQH5PwnXOA1Lxz51qp1
t0VumE3B1RDheiBTE8loG1FvmikOiek2gzz76nK0R1sbKY1+/NVJpMs6dL6NzJXG
N6aCpWTk7oeY+lW5bPBG0VRA7RUG80w9R9RDtqYc0SYUmm43tjjxPZ81rhCXFx/F
v1kxnNTQJdATNrTn9SofymSfm42f4loOGyGBsqJYybKXOPDxrM1erBN5eCwTpJMS
4J30aMSdQTzza2Z4wi2LR0vq/FU/ouqzlRp7+7tNJbVAsqhiUa2eeAVkFwZl9wRw
lddY0W85U507nw5M3iQv2GTOhJRXwhWpzDUFQ0fT56hAY/V+VbF1iHGAVIz4XlUj
gC21wuXz0xRdqP8cCd8UHLSbp8dmie161GeKVwO037aP+1hZJbm7ePsS5Na+qYG1
LCy0GhfQn71BsYUaGJtfRcaMwIbqaNIYn+Y6S1FVjxDPXCxFXDrIcFvldmJYTyeK
7KrkO2P1RbEiwYyPPUhthbb1Agi9ZutZsnadmPRk27t9bBjNnWaY2z17hijnzVVz
jOHuPlpb7cSaagVzLTT0zrZ+ifnZWwdl0S2ZrjBAeVrkNt7DOCUqwBnuBqYiRZFt
A1QicHxaEQIDAQABo20wazAdBgNVHQ4EFgQU3l25Ghab2k7UhwxftZ2vZ1HO9Sow
HwYDVR0jBBgwFoAU3l25Ghab2k7UhwxftZ2vZ1HO9SowDwYDVR0TAQH/BAUwAwEB
/zAYBgNVHREEETAPgg1yYWJiaXRtcS10ZXN0MA0GCSqGSIb3DQEBCwUAA4ICAQB9
ilcsRqIvnyN25Oh668YC/xxyeNTIaIxjMLyJaMylBRjNwo1WfbdpXToaEXgot5gK
5HGlu3OIBBwBryNAlBtf/usxzLzmkEsm1Dsn9sJNY1ZTkD8MO9yyOtLqBlqAsIse
oPVjzSdjk1fP3uyoG/ZUVAFZHZD3/9BEsftfS13oUVxo7vYz1DSyUATT/4QTYMQB
PytL6EKJ0dLyuy7rIkZVkaUi+P7GuDXj25Mi6Zkxaw2QnssSuoqy1bAMkzEyNFK5
0wlNWEY8H3jRZuAz1T4AXb9sjeCgBKZoWXgmGbzleOophdzvlq66UGAWPWYFGp8Q
4GJognovhKzSY9+3n+rMPLAXSao48SYDlyTOZeBo1DTluR5QjVd+NWbEdIsA6buQ
a6uPTSVKsulm7hyUlEZp+SsYAtVoZx3jzKKjZXjnaxOfUFWx6pTxNXvxR7pQ/8Ls
IfduGy4VjKVQdyuwCE7eVEPDK6d53WWs6itziuj7gfq8mHvZivIA65z05lTwqkvb
1WS2aht+zacqVSYyNrK+/kJA2CST3ggc1EO73lRvbfO9LJZWMdO+f/tkXH4zkfmL
A3JtJcLOWuv+ZrZvHMpKlBFNMySxE3IeGX+Ad9bGyhZvZULut95/QD7Xy4cPRZHF
R3SRn0rn/BeTly+5fkEoFk+ttah8IbwzhduPyPIxng==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQC9OjTq4DgqVo0m
RpS8DGRR6SFrSpb2bqnlYI7xSI3y67i/oP4weiZSawk2+euxhsN4FfOlsAgvpg4W
yRQH5PwnXOA1Lxz51qp1t0VumE3B1RDheiBTE8loG1FvmikOiek2gzz76nK0R1sb
KY1+/NVJpMs6dL6NzJXGN6aCpWTk7oeY+lW5bPBG0VRA7RUG80w9R9RDtqYc0SYU
mm43tjjxPZ81rhCXFx/Fv1kxnNTQJdATNrTn9SofymSfm42f4loOGyGBsqJYybKX
OPDxrM1erBN5eCwTpJMS4J30aMSdQTzza2Z4wi2LR0vq/FU/ouqzlRp7+7tNJbVA
sqhiUa2eeAVkFwZl9wRwlddY0W85U507nw5M3iQv2GTOhJRXwhWpzDUFQ0fT56hA
Y/V+VbF1iHGAVIz4XlUjgC21wuXz0xRdqP8cCd8UHLSbp8dmie161GeKVwO037aP
+1hZJbm7ePsS5Na+qYG1LCy0GhfQn71BsYUaGJtfRcaMwIbqaNIYn+Y6S1FVjxDP
XCxFXDrIcFvldmJYTyeK7KrkO2P1RbEiwYyPPUhthbb1Agi9ZutZsnadmPRk27t9
bBjNnWaY2z17hijnzVVzjOHuPlpb7cSaagVzLTT0zrZ+ifnZWwdl0S2ZrjBAeVrk
Nt7DOCUqwBnuBqYiRZFtA1QicHxaEQIDAQABAoICAA+AWvDpzNgVDouV6R3NkxNN
upXgPqUx9BuNETCtbal6i4AxR1l/zC9gwti82QTKQi2OeM74MHd8zjcqIkiyRsDP
wDNDKIfEAONTT+4LLoWEN5WNDGRZ4Nw1LrLqiVX+ULtNPXvynRJtLQa43PVL74oQ
pLBle23A1n0uNmcJ9w21B6ktysN9q+JVSCZodZpD6Jk1jus8JXgDXy/9Za2NMTV8
A5ShbYz/ETSBJCSnERz7GARW7TN6V0jS6vLTSqMQJyn0KYbHNDr7TPTL7psRuaI5
jP/cqxmx1/WKLo5k3cR3IW/cesDGQXZhMRQvNymXJkxvWMPS36lmfyZtbFNflw4Z
9OD+2RKt5jFDJjG8fYiYoYBdLiTj2Wdvo4mbRPNkTL75o65riDkDCQuZhDXFBm3s
B1aDv5y1AXrzNZ5JSikszKgbLNPYB0rI3unp6i0P1985w6dyel0MGG+ouaeiyrxS
9IgJDnE4BJ79mEzHTXtbZ/+3aGAK/Y6mU8Pz2s6/+6ccT0miievsMS+si1KESF31
WLnsMdcrJcxqcm7Ypo24G0yBJluSDKtD1cqQUGN1MKp+EEv1SCH+4csaa3ooRB0o
YveySjqxtmhVpQuY3egCOaXhPmX7lgYwoe+G4UIkUMwPn20WMg+jFxgPASdh4lqE
mzpePP7STvEZAr+rrLu1AoIBAQDmCEiKOsUTtJlX3awOIRtCkIqBxS1E6rpyjfxK
A6+zpXnE++8MhIJ07+9bPdOshGjS3JbJ+hu+IocbNg++rjRArYQnJh8/qBZ2GB2v
Ryfptsoxtk/xUsmOfchvk4tOjvDHZrJehUtGc+LzX/WUqpgtEk1Gnx7RGRuDNnqS
Q1+yU4NubHwOHPswBBXOnVtopcAHFpKhbKRFOHOwMZN99qcWVIkv4J9c6emcPMLI
I/QPIvwB6WmbLa0o3JNXlD4kPdqCgNW36KEFiW8m+4tgzF3HWYSAyIeBRFG7ouE6
yk5hiptPKhZlTmTAkQSssCXksiTw1rsspFULZSRyaaaPunvVAoIBAQDSlrKu+B2h
AJtxWy5MQDOiroqT3KDneIGXPYgH3/tiDmxy0CIEbSb5SqZ6zAmihs3dWWCmc1JH
YObRrqIxu+qVi4K+Uz8l7WBrS7DkjZjajq+y/mrZYUNRoL2q9mnNqRNan7zxWDJc
U4u2NH9P4LOz6ttE4OG9SC3/gZLoepA+ANZatu93749IT7z8ske0MVPP76jVI1Gl
D7cPIlzcBUdJgNV8UOkxeqU3+S6Jn17Tkx5qMWND/2BCN4voQ4pfGWSkbaHlMLh1
2SbVuR+HYPY3aPJeSY7MEPoc7d2SSVOcVDr2AQwSDSCCgIFZOZlawehUz9R51hK8
LlaccFWXhS9NAoIBAEFZNRJf48DXW4DErq5M5WuhmFeJZnTfohwNDhEQvwdwCQnW
8HBD7LO/veXTyKCH9SeCFyxF6z+2m181mn93Cc0d/h8JC3OQEuF1tGko88PHc+Vv
f4J1HGFohlp8NeUZYnmjSSTlBR98qIqvRhr348daHa3kYmLQmSpLfcKzdSo542qp
UwzHWuynHHLX7THrdIQO+5T0Qi6P/P2e9+GfApSra1W4oE1K/lyuPj+RRzJNo/3/
C0tUTI8BKrKEoKq3D65nX0+hvKzQAE24xD25kSKi4aucTDKC8B04BngnJOE8+SYi
NL6O6Lxz9joAyKMRoMDyn7Xs8WQNVa9TKEhImAkCggEBAMljmIm/egZIoF7thf8h
vr+rD5eL/Myf776E95wgVTVW+dtqs71r7UOmYkM48VXeeO1f1hAYZO0h/Fs2GKJb
RWGyQ1xkHBXXRsgVYJuR1kXdAqW4rNIqM8jSYdAnStOFB5849+YOJEsrEocy+TWY
fAJpbTwXm4n6hxK8BZQR8fN5tYSXQbd+/5V1vBQlInFuYuqOFPWPizrBJp1wjUFU
QvJGJON4NSo+UdaPlDPEl1jabtG7XWTfylxI5qE+RgvgKuEcfyDBUQZSntLw8Pf0
gEJJOM92pPr+mVIlICoPucfcvW4ZXkO9DgP/hLOhY8jpe5fwERBa6xvPbMC6pP/8
PFkCggEBAOLtvboBThe57QRphsKHmCtRJHmT4oZzhMYsE+5GMGYzPNWod1hSyfXn
EB8iTmAFP5r7FdC10B8mMpACXuDdi2jbmlYOTU6xNTprSKtv8r8CvorWJdsQwRsy
pZ7diSCeyi0z/sIx//ov0b3WD0E8BG/HWsFbX0p5xXpaljYEv5dK7xUiWgBW+15a
N1AeVcPiXRDwhQMVcvVOvzgwKsw+Rpls/9W4hihcBHaiMcBUDFWxJtnf4ZAGAZS3
/694MOYlmfgT/cDqF9oOsCdxM0w24kL0dcUM7zPk314ixAAfUwXaxisBhS2roJ88
HsuK9JPSK/AS0IqUtKiq4LZ9ErixYF0=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,35 @@
{
"users": [
{
"name": "gitea",
"password_hash": "5IdZmMJhNb4otX/nz9Xtmkpj9khl6+5eAmXNs/oHYwQNO3jg",
"hashing_algorithm": "rabbit_password_hashing_sha256",
"tags": "administrator"
}
],
"vhosts": [
{
"name": "/"
}
],
"permissions": [
{
"user": "gitea",
"vhost": "/",
"configure": ".*",
"write": ".*",
"read": ".*"
}
],
"exchanges": [
{
"name": "pubsub",
"vhost": "/",
"type": "topic",
"durable": true,
"auto_delete": false,
"internal": false,
"arguments": {}
}
]
}

83
integration/test-plan.md Normal file
View File

@@ -0,0 +1,83 @@
# Test Plan: workflow-pr Bot
## 1. Introduction
This document outlines the test plan for the `workflow-pr` bot. The bot is responsible for synchronizing pull requests between ProjectGit and PackageGit repositories, managing reviews, and handling merges. This test plan aims to ensure the bot's functionality, reliability, and performance.
## 2. Scope
### In Scope
* Pull Request synchronization (creation, update, closing).
* Reviewer management (adding, re-adding, mandatory vs. advisory).
* Merge management, including `ManualMergeOnly` and `ManualMergeProject` flags.
* Configuration parsing (`workflow.config`).
* Label management (`staging/Auto`, `review/Pending`, `review/Done`).
* Maintainership and permissions handling.
### Out of Scope
* Package deletion requests (planned feature).
* Underlying infrastructure (Gitea, RabbitMQ, OBS).
* Performance and load testing.
* Closing a PackageGit PR (currently disabled).
## 3. Test Objectives
* Verify that pull requests are correctly synchronized between ProjectGit and PackageGit.
* Ensure that reviewers are correctly added to pull requests based on the configuration.
* Validate that pull requests are merged only when all conditions are met.
* Confirm that the bot correctly handles various configurations in `workflow.config`.
* Verify that labels are correctly applied to pull requests.
* Ensure that maintainership and permissions are correctly enforced.
## 4. Test Strategy
The testing will be conducted in a dedicated test environment that mimics the production environment. The strategy will involve a combination of:
* **Component Testing:** Testing individual components of the bot in isolation using unit tests written in Go.
* **Integration Testing:** Testing the bot's interaction with Gitea, RabbitMQ, and a mock OBS server using `pytest`.
* **End-to-End Testing:** Testing the complete workflow from creating a pull request to merging it using `pytest`.
### Test Automation
* **Unit Tests:** Go's built-in testing framework will be used to write unit tests for individual functions and methods.
* **Integration and End-to-End Tests:** `pytest` will be used to write integration and end-to-end tests that use the Gitea API to create pull requests and verify the bot's behavior.
### Success Metrics
* **Test Coverage:** The goal is to achieve at least 80% test coverage for the bot's codebase.
* **Bug Detection Rate:** The number of bugs found during the testing phase.
* **Test Pass Rate:** The percentage of test cases that pass without any issues.
## 5. Test Cases
| Test Case ID | Description | Steps to Reproduce | Expected Results | Priority |
| :--- | :--- | :--- | :--- | :--- |
| **TC-SYNC-001** | **Create ProjectGit PR from PackageGit PR** | 1. Create a new PR in a PackageGit repository. | 1. A new PR is created in the corresponding ProjectGit repository with the title "Forwarded PRs: <package_name>".<br>2. The ProjectGit PR description contains a link to the PackageGit PR (e.g., `PR: org/package_repo!pr_number`).<br>3. The package submodule in the ProjectGit PR points to the PackageGit PR's commit. | High |
| **TC-SYNC-002** | **Update ProjectGit PR from PackageGit PR** | 1. Push a new commit to an existing PackageGit PR. | 1. The corresponding ProjectGit PR's head branch is updated with the new commit. | High |
| **TC-SYNC-003** | **WIP Flag Synchronization** | 1. Mark a PackageGit PR as "Work In Progress".<br>2. Remove the WIP flag from the PackageGit PR. | 1. The corresponding ProjectGit PR is also marked as "Work In Progress".<br>2. The WIP flag on the ProjectGit PR is removed. | Medium |
| **TC-SYNC-004** | **WIP Flag (multiple referenced package PRs)** | 1. Create a ProjectGit PR that references multiple PackageGit PRs.<br>2. Mark one of the PackageGit PRs as "Work In Progress".<br>3. Remove the "Work In Progress" flag from all PackageGit PRs. | 1. The ProjectGit PR is marked as "Work In Progress".<br>2. The "Work In Progress" flag is removed from the ProjectGit PR only after it has been removed from all associated PackageGit PRs. | Medium |
| **TC-SYNC-005** | **NoProjectGitPR = true, edits disabled** | 1. Set `NoProjectGitPR = true` in `workflow.config`.<br>2. Create a PackageGit PR without "Allow edits from maintainers" enabled. <br>3. Push a new commit to the PackageGit PR. | 1. No ProjectGit PR is created.<br>2. The bot adds a warning comment to the PackageGit PR explaining that it cannot update the PR. | High |
| **TC-SYNC-006** | **NoProjectGitPR = true, edits enabled** | 1. Set `NoProjectGitPR = true` in `workflow.config`.<br>2. Create a PackageGit PR with "Allow edits from maintainers" enabled.<br>3. Push a new commit to the PackageGit PR. | 1. No ProjectGit PR is created.<br>2. The submodule commit on the project PR is updated with the new commit from the PackageGit PR. | High |
| **TC-COMMENT-001** | **Detect duplicate comments** | 1. Create a PackageGit PR.<br>2. Wait for the `workflow-pr` bot to act on the PR.<br>3. Edit the body of the PR to trigger the bot a second time. | 1. The bot should not post a duplicate comment. | High |
| **TC-REVIEW-001** | **Add mandatory reviewers** | 1. Create a new PackageGit PR. | 1. All mandatory reviewers are added to both the PackageGit and ProjectGit PRs. | High |
| **TC-REVIEW-002** | **Add advisory reviewers** | 1. Create a new PackageGit PR with advisory reviewers defined in the configuration. | 1. Advisory reviewers are added to the PR, but their approval is not required for merging. | Medium |
| **TC-REVIEW-003** | **Re-add reviewers** | 1. Push a new commit to a PackageGit PR after it has been approved. | 1. The original reviewers are re-added to the PR. | Medium |
| **TC-REVIEW-004** | **Package PR created by a maintainer** | 1. Create a PackageGit PR from the account of a package maintainer. | 1. No review is requested from other package maintainers. | High |
| **TC-REVIEW-005** | **Package PR created by an external user (approve)** | 1. Create a PackageGit PR from the account of a user who is not a package maintainer.<br>2. One of the package maintainers approves the PR. | 1. All package maintainers are added as reviewers.<br>2. Once one maintainer approves the PR, the other maintainers are removed as reviewers. | High |
| **TC-REVIEW-006** | **Package PR created by an external user (reject)** | 1. Create a PackageGit PR from the account of a user who is not a package maintainer.<br>2. One of the package maintainers rejects the PR. | 1. All package maintainers are added as reviewers.<br>2. Once one maintainer rejects the PR, the other maintainers are removed as reviewers. | High |
| **TC-REVIEW-007** | **Package PR created by a maintainer with ReviewRequired=true** | 1. Set `ReviewRequired = true` in `workflow.config`.<br>2. Create a PackageGit PR from the account of a package maintainer. | 1. A review is requested from other package maintainers if available. | High |
| **TC-MERGE-001** | **Automatic Merge** | 1. Create a PackageGit PR.<br>2. Ensure all mandatory reviews are completed on both project and package PRs. | 1. The PR is automatically merged. | High |
| **TC-MERGE-002** | **ManualMergeOnly with Package Maintainer** | 1. Create a PackageGit PR with `ManualMergeOnly` set to `true`.<br>2. Ensure all mandatory reviews are completed on both project and package PRs.<br>3. Comment "merge ok" on the package PR from the account of a package maintainer for that package. | 1. The PR is merged. | High |
| **TC-MERGE-003** | **ManualMergeOnly with unauthorized user** | 1. Create a PackageGit PR with `ManualMergeOnly` set to `true`.<br>2. Ensure all mandatory reviews are completed on both project and package PRs.<br>3. Comment "merge ok" on the package PR from the account of a user who is not a maintainer for that package. | 1. The PR is not merged. | High |
| **TC-MERGE-004** | **ManualMergeOnly with multiple packages** | 1. Create a ProjectGit PR that references multiple PackageGit PRs with `ManualMergeOnly` set to `true`.<br>2. Ensure all mandatory reviews are completed on both project and package PRs.<br>3. Comment "merge ok" on each package PR from the account of a package maintainer. | 1. The PR is merged only after "merge ok" is commented on all associated PackageGit PRs. | High |
| **TC-MERGE-005** | **ManualMergeOnly with Project Maintainer** | 1. Create a PackageGit PR with `ManualMergeOnly` set to `true`.<br>2. Ensure all mandatory reviews are completed on both project and package PRs.<br>3. Comment "merge ok" on the package PR from the account of a project maintainer. | 1. The PR is merged. | High |
| **TC-MERGE-006** | **ManualMergeProject with Project Maintainer** | 1. Create a PackageGit PR with `ManualMergeProject` set to `true`.<br>2. Ensure all mandatory reviews are completed on both project and package PRs.<br>3. Comment "merge ok" on the project PR from the account of a project maintainer. | 1. The PR is merged. | High |
| **TC-MERGE-007** | **ManualMergeProject with unauthorized user** | 1. Create a PackageGit PR with `ManualMergeProject` set to `true`.<br>2. Ensure all mandatory reviews are completed on both project and package PRs.<br>3. Comment "merge ok" on the project PR from the account of a package maintainer. | 1. The PR is not merged. | High |
| **TC-CONFIG-001** | **Invalid Configuration** | 1. Provide an invalid `workflow.config` file. | 1. The bot reports an error and does not process any PRs. | High |
| **TC-LABEL-001** | **Apply `staging/Auto` label** | 1. Create a new PackageGit PR. | 1. The `staging/Auto` label is applied to the ProjectGit PR. | High |
| **TC-LABEL-002** | **Apply `review/Pending` label** | 1. Create a new PackageGit PR. | 1. The `review/Pending` label is applied to the ProjectGit PR when there are pending reviews. | Medium |
| **TC-LABEL-003** | **Apply `review/Done` label** | 1. Ensure all mandatory reviews for a PR are completed. | 1. The `review/Done` label is applied to the ProjectGit PR when all mandatory reviews are completed. | Medium |

View File

View File

@@ -0,0 +1,78 @@
"""
This module contains pytest fixtures for setting up the test environment.
"""
import pytest
import requests
import time
import os
# Assuming GiteaAPIClient is in tests/lib/common_test_utils.py
from tests.lib.common_test_utils import GiteaAPIClient
@pytest.fixture(scope="session")
def gitea_env():
"""
Sets up the Gitea environment with dummy data and provides a GiteaAPIClient instance.
"""
gitea_url = "http://127.0.0.1:3000"
# Read admin token
admin_token_path = "./gitea-data/admin.token" # Corrected path
admin_token = None
try:
with open(admin_token_path, "r") as f:
admin_token = f.read().strip()
except FileNotFoundError:
raise Exception(f"Admin token file not found at {admin_token_path}. Ensure it's generated and accessible.")
# Headers for authenticated requests
auth_headers = {"Authorization": f"token {admin_token}", "Content-Type": "application/json"}
# Wait for Gitea to be available
print(f"Waiting for Gitea at {gitea_url}...")
max_retries = 30
for i in range(max_retries):
try:
# Check a specific API endpoint that indicates readiness
response = requests.get(f"{gitea_url}/api/v1/version", headers=auth_headers, timeout=5)
if response.status_code == 200:
print("Gitea API is available.")
break
except requests.exceptions.ConnectionError:
pass
print(f"Gitea not ready ({response.status_code if 'response' in locals() else 'ConnectionError'}), retrying in 5 seconds... ({i+1}/{max_retries})")
time.sleep(5)
else:
raise Exception("Gitea did not become available within the expected time.")
client = GiteaAPIClient(base_url=gitea_url, token=admin_token)
# Setup dummy data
print("--- Starting Gitea Dummy Data Setup from Pytest Fixture ---")
client.create_org("products")
client.create_org("pool")
client.create_repo("products", "SLFO")
client.create_repo("pool", "pkgA")
client.create_repo("pool", "pkgB")
# The add_submodules method also creates workflow.config and staging.config
client.add_submodules("products", "SLFO")
client.add_collaborator("products", "SLFO", "autogits_obs_staging_bot", "write")
client.add_collaborator("products", "SLFO", "workflow-pr", "write")
client.add_collaborator("pool", "pkgA", "workflow-pr", "write")
client.add_collaborator("pool", "pkgB", "workflow-pr", "write")
client.update_repo_settings("products", "SLFO")
client.update_repo_settings("pool", "pkgA")
client.update_repo_settings("pool", "pkgB")
print("--- Gitea Dummy Data Setup Complete ---")
time.sleep(5) # Add a small delay for Gitea to fully process changes
yield client
# Teardown (optional, depending on test strategy)
# For now, we'll leave resources for inspection. If a clean slate is needed for each test,
# this fixture's scope would be 'function' and teardown logic would be added here.

View File

@@ -0,0 +1,23 @@
<resultlist state="0fef640bfb56c3e76fcfb698b19b59c0">
<result project="SUSE:SLFO:Main:PullRequest:1881" repository="standard" arch="aarch64" code="unpublished" state="unpublished">
<scmsync>https://src.suse.de/products/SLFO.git?onlybuild=openjpeg2#d99ac14dedf9f44e1744c71aaf221d15f6bed479ca11f15738e98f3bf9ae05a1</scmsync>
<scminfo>d99ac14dedf9f44e1744c71aaf221d15f6bed479ca11f15738e98f3bf9ae05a1</scminfo>
<status package="openjpeg2" code="succeeded"/>
</result>
<result project="SUSE:SLFO:Main:PullRequest:1881" repository="standard" arch="ppc64le" code="unpublished" state="unpublished">
<scmsync>https://src.suse.de/products/SLFO.git?onlybuild=openjpeg2#d99ac14dedf9f44e1744c71aaf221d15f6bed479ca11f15738e98f3bf9ae05a1</scmsync>
<scminfo>d99ac14dedf9f44e1744c71aaf221d15f6bed479ca11f15738e98f3bf9ae05a1</scminfo>
<status package="openjpeg2" code="succeeded"/>
</result>
<result project="SUSE:SLFO:Main:PullRequest:1881" repository="standard" arch="x86_64" code="unpublished" state="unpublished">
<scmsync>https://src.suse.de/products/SLFO.git?onlybuild=openjpeg2#d99ac14dedf9f44e1744c71aaf221d15f6bed479ca11f15738e98f3bf9ae05a1</scmsync>
<scminfo>d99ac14dedf9f44e1744c71aaf221d15f6bed479ca11f15738e98f3bf9ae05a1</scminfo>
<status package="openjpeg2" code="succeeded"/>
</result>
<result project="SUSE:SLFO:Main:PullRequest:1881" repository="standard" arch="s390x" code="unpublished" state="unpublished">
<scmsync>https://src.suse.de/products/SLFO.git?onlybuild=openjpeg2#d99ac14dedf9f44e1744c71aaf221d15f6bed479ca11f15738e98f3bf9ae05a1</scmsync>
<scminfo>d99ac14dedf9f44e1744c71aaf221d15f6bed479ca11f15738e98f3bf9ae05a1</scminfo>
<status package="openjpeg2" code="succeeded"/>
</result>
</resultlist>

View File

@@ -0,0 +1,18 @@
<project name="openSUSE:Leap:16.0:PullRequest">
<title>Leap 16.0 PullRequest area</title>
<description>Base project to define the pull request builds</description>
<person userid="autogits_obs_staging_bot" role="maintainer"/>
<person userid="maxlin_factory" role="maintainer"/>
<group groupid="maintenance-opensuse.org" role="maintainer"/>
<debuginfo>
<enable/>
</debuginfo>
<repository name="standard">
<path project="openSUSE:Leap:16.0" repository="standard"/>
<arch>x86_64</arch>
<arch>i586</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
</project>

View File

@@ -0,0 +1,59 @@
<project name="openSUSE:Leap:16.0">
<title>openSUSE Leap 16.0 based on SLFO</title>
<description>Leap 16.0 based on SLES 16.0 (specifically SLFO:1.2)</description>
<link project="openSUSE:Backports:SLE-16.0"/>
<scmsync>http://gitea-test:3000/products/SLFO#main</scmsync>
<person userid="dimstar_suse" role="maintainer"/>
<person userid="lkocman-factory" role="maintainer"/>
<person userid="maxlin_factory" role="maintainer"/>
<person userid="factory-auto" role="reviewer"/>
<person userid="licensedigger" role="reviewer"/>
<group groupid="autobuild-team" role="maintainer"/>
<group groupid="factory-maintainers" role="maintainer"/>
<group groupid="maintenance-opensuse.org" role="maintainer"/>
<group groupid="factory-staging" role="reviewer"/>
<build>
<disable repository="ports"/>
</build>
<debuginfo>
<enable/>
</debuginfo>
<repository name="standard" rebuild="local">
<path project="openSUSE:Backports:SLE-16.0" repository="standard"/>
<path project="SUSE:SLFO:1.2" repository="standard"/>
<arch>local</arch>
<arch>i586</arch>
<arch>x86_64</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
<repository name="product">
<releasetarget project="openSUSE:Leap:16.0:ToTest" repository="product" trigger="manual"/>
<path project="openSUSE:Leap:16.0:NonFree" repository="standard"/>
<path project="openSUSE:Leap:16.0" repository="images"/>
<path project="openSUSE:Leap:16.0" repository="standard"/>
<path project="openSUSE:Backports:SLE-16.0" repository="standard"/>
<path project="SUSE:SLFO:1.2" repository="standard"/>
<arch>local</arch>
<arch>i586</arch>
<arch>x86_64</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
<repository name="ports">
<arch>armv7l</arch>
</repository>
<repository name="images">
<releasetarget project="openSUSE:Leap:16.0:ToTest" repository="images" trigger="manual"/>
<path project="openSUSE:Leap:16.0" repository="standard"/>
<path project="openSUSE:Backports:SLE-16.0" repository="standard"/>
<path project="SUSE:SLFO:1.2" repository="standard"/>
<arch>i586</arch>
<arch>x86_64</arch>
<arch>aarch64</arch>
<arch>ppc64le</arch>
<arch>s390x</arch>
</repository>
</project>

View File

@@ -0,0 +1,301 @@
import os
import time
import pytest
import requests
import json
import xml.etree.ElementTree as ET
from pathlib import Path
import base64
TEST_DATA_DIR = Path(__file__).parent.parent / "data"
BUILD_RESULT_TEMPLATE = TEST_DATA_DIR / "build_result.xml.template"
MOCK_RESPONSES_DIR = Path(__file__).parent.parent.parent / "mock-obs" / "responses"
MOCK_BUILD_RESULT_FILE = (
MOCK_RESPONSES_DIR / "GET_build_openSUSE:Leap:16.0:PullRequest:*__result"
)
MOCK_BUILD_RESULT_FILE1 = MOCK_RESPONSES_DIR / "GET_build_openSUSE:Leap:16.0__result"
@pytest.fixture
def mock_build_result():
"""
Fixture to create a mock build result file from the template.
Returns a factory function that the test can call with parameters.
"""
def _create_result_file(package_name: str, code: str):
tree = ET.parse(BUILD_RESULT_TEMPLATE)
root = tree.getroot()
for status_tag in root.findall(".//status"):
status_tag.set("package", package_name)
status_tag.set("code", code)
MOCK_RESPONSES_DIR.mkdir(exist_ok=True)
tree.write(MOCK_BUILD_RESULT_FILE)
tree.write(MOCK_BUILD_RESULT_FILE1)
return str(MOCK_BUILD_RESULT_FILE)
yield _create_result_file
if MOCK_BUILD_RESULT_FILE.exists():
MOCK_BUILD_RESULT_FILE.unlink()
MOCK_BUILD_RESULT_FILE1.unlink()
class GiteaAPIClient:
def __init__(self, base_url, token):
self.base_url = base_url
self.headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _request(self, method, path, **kwargs):
url = f"{self.base_url}/api/v1/{path}"
response = requests.request(method, url, headers=self.headers, **kwargs)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
print(f"HTTPError in _request: {e}")
print(f"Response Content: {e.response.text}")
raise
return response
def create_org(self, org_name):
print(f"--- Checking organization: {org_name} ---")
try:
self._request("GET", f"orgs/{org_name}")
print(f"Organization '{org_name}' already exists.")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
print(f"Creating organization '{org_name}'...")
data = {"username": org_name, "full_name": org_name}
self._request("POST", "orgs", json=data)
print(f"Organization '{org_name}' created.")
else:
raise
def create_repo(self, org_name, repo_name):
print(f"--- Checking repository: {org_name}/{repo_name} ---")
try:
self._request("GET", f"repos/{org_name}/{repo_name}")
print(f"Repository '{org_name}/{repo_name}' already exists.")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
print(f"Creating repository '{org_name}/{repo_name}'...")
data = {
"name": repo_name,
"auto_init": True,
"default_branch": "main",
"gitignores": "Go",
"license": "MIT",
"private": False,
"readme": "Default"
}
self._request("POST", f"orgs/{org_name}/repos", json=data)
print(f"Repository '{org_name}/{repo_name}' created with a README.")
time.sleep(1) # Added delay to allow Git operations to become available
else:
raise
def add_collaborator(self, org_name, repo_name, collaborator_name, permission="write"):
print(f"--- Adding {collaborator_name} as a collaborator to {org_name}/{repo_name} with '{permission}' permission ---")
data = {"permission": permission}
# Gitea API returns 204 No Content on success and doesn't fail if already present.
self._request("PUT", f"repos/{org_name}/{repo_name}/collaborators/{collaborator_name}", json=data)
print(f"Attempted to add {collaborator_name} to {org_name}/{repo_name}.")
def add_submodules(self, org_name, repo_name):
print(f"--- Adding submodules to {org_name}/{repo_name} using diffpatch ---")
parent_repo_path = f"repos/{org_name}/{repo_name}"
try:
self._request("GET", f"{parent_repo_path}/contents/.gitmodules")
print("Submodules appear to be already added. Skipping.")
return
except requests.exceptions.HTTPError as e:
if e.response.status_code != 404:
raise
# Get latest commit SHAs for the submodules
pkg_a_sha = self._request("GET", "repos/pool/pkgA/branches/main").json()["commit"]["id"]
pkg_b_sha = self._request("GET", "repos/pool/pkgB/branches/main").json()["commit"]["id"]
if not pkg_a_sha or not pkg_b_sha:
raise Exception("Error: Could not get submodule commit SHAs. Cannot apply patch.")
diff_content = f"""diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..f1838bd
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+[submodule "pkgA"]
+ path = pkgA
+ url = ../../pool/pkgA.git
+[submodule "pkgB"]
+ path = pkgB
+ url = ../../pool/pkgB.git
diff --git a/pkgA b/pkgA
new file mode 160000
index 0000000..{pkg_a_sha}
--- /dev/null
+++ b/pkgA
@@ -0,0 +1 @@
+Subproject commit {pkg_a_sha}
diff --git a/pkgB b/pkgB
new file mode 160000
index 0000000..{pkg_b_sha}
--- /dev/null
+++ b/pkgB
@@ -0,0 +1 @@
+Subproject commit {pkg_b_sha}
diff --git a/workflow.config b/workflow.config
new file mode 100644
--- /dev/null
+++ b/workflow.config
@@ -0,0 +7 @@
+{{
+ "Workflows": ["pr"],
+ "GitProjectName": "products/SLFO#main",
+ "Organization": "pool",
+ "Branch": "main",
+ "ManualMergeProject": true,
+ "Reviewers": [ "-autogits_obs_staging_bot" ]
+}}
diff --git a/staging.config b/staging.config
new file mode 100644
--- /dev/null
+++ b/staging.config
@@ -0,0 +3 @@
+{{
+ "ObsProject": "openSUSE:Leap:16.0",
+ "StagingProject": "openSUSE:Leap:16.0:PullRequest"
+}}
"""
message = "Add pkgA and pkgB as submodules and config files"
data = {
"branch": "main",
"content": diff_content,
"message": message
}
print(f"Applying submodule patch to {org_name}/{repo_name}...")
self._request("POST", f"{parent_repo_path}/diffpatch", json=data)
print("Submodule patch applied.")
def update_repo_settings(self, org_name, repo_name):
print(f"--- Updating repository settings for: {org_name}/{repo_name} ---")
repo_data = self._request("GET", f"repos/{org_name}/{repo_name}").json()
# Ensure these are boolean values, not string
repo_data["allow_manual_merge"] = True
repo_data["autodetect_manual_merge"] = True
self._request("PATCH", f"repos/{org_name}/{repo_name}", json=repo_data)
print(f"Repository settings for '{org_name}/{repo_name}' updated.")
def create_gitea_pr(self, repo_full_name: str, diff_content: str, title: str):
owner, repo = repo_full_name.split("/")
url = f"repos/{owner}/{repo}/pulls"
base_branch = "main"
# Create a new branch for the PR
new_branch_name = f"pr-branch-{int(time.time())}"
# Get the latest commit SHA of the base branch
base_commit_sha = self._request("GET", f"repos/{owner}/{repo}/branches/{base_branch}").json()["commit"]["id"]
# Create the new branch
self._request("POST", f"repos/{owner}/{repo}/branches", json={
"new_branch_name": new_branch_name,
"old_ref": base_commit_sha # Use the commit SHA directly
})
# Create a new file or modify an existing one in the new branch
file_path = f"test-file-{int(time.time())}.txt"
file_content = "This is a test file for the PR."
self._request("POST", f"repos/{owner}/{repo}/contents/{file_path}", json={
"content": base64.b64encode(file_content.encode('utf-8')).decode('ascii'),
"message": "Add test file",
"branch": new_branch_name
})
# Now create the PR
data = {
"head": new_branch_name, # Use the newly created branch as head
"base": base_branch,
"title": title,
"body": "Test Pull Request"
}
response = self._request("POST", url, json=data)
return response.json()
def modify_gitea_pr(self, repo_full_name: str, pr_number: int, diff_content: str, message: str):
owner, repo = repo_full_name.split("/")
# Get PR details to find the head branch
pr_details = self._request("GET", f"repos/{owner}/{repo}/pulls/{pr_number}").json()
head_branch = pr_details["head"]["ref"]
file_path = f"modified-file-{int(time.time())}.txt"
file_content = "This is a modified test file for the PR."
self._request("POST", f"repos/{owner}/{repo}/contents/{file_path}", json={
"content": base64.b64encode(file_content.encode('utf-8')).decode('ascii'),
"message": message,
"branch": head_branch
})
def update_gitea_pr_properties(self, repo_full_name: str, pr_number: int, **kwargs):
owner, repo = repo_full_name.split("/")
url = f"repos/{owner}/{repo}/pulls/{pr_number}"
response = self._request("PATCH", url, json=kwargs)
return response.json()
def get_timeline_events(self, repo_full_name: str, pr_number: int):
owner, repo = repo_full_name.split("/")
url = f"repos/{owner}/{repo}/issues/{pr_number}/timeline"
# Retry logic for timeline events
for i in range(10): # Try up to 10 times
try:
response = self._request("GET", url)
timeline_events = response.json()
if timeline_events: # Check if timeline_events list is not empty
return timeline_events
print(f"Attempt {i+1}: Timeline for PR {pr_number} is empty. Retrying in 3 seconds...")
time.sleep(3)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
print(f"Attempt {i+1}: Timeline for PR {pr_number} not found yet. Retrying in 3 seconds...")
time.sleep(3)
else:
raise # Re-raise other HTTP errors
raise Exception(f"Failed to retrieve timeline for PR {pr_number} after multiple retries.")
def get_comments(self, repo_full_name: str, pr_number: int):
owner, repo = repo_full_name.split("/")
url = f"repos/{owner}/{repo}/issues/{pr_number}/comments"
# Retry logic for comments
for i in range(10): # Try up to 10 times
try:
response = self._request("GET", url)
comments = response.json()
print(f"Attempt {i+1}: Comments for PR {pr_number} received: {comments}") # Added debug print
if comments: # Check if comments list is not empty
return comments
print(f"Attempt {i+1}: Comments for PR {pr_number} are empty. Retrying in 3 seconds...")
time.sleep(3)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
print(f"Attempt {i+1}: Comments for PR {pr_number} not found yet. Retrying in 3 seconds...")
time.sleep(3)
else:
raise # Re-raise other HTTP errors
raise Exception(f"Failed to retrieve comments for PR {pr_number} after multiple retries.")
def get_pr_details(self, repo_full_name: str, pr_number: int):
owner, repo = repo_full_name.split("/")
url = f"repos/{owner}/{repo}/pulls/{pr_number}"
response = self._request("GET", url)
return response.json()

View File

@@ -0,0 +1,153 @@
import pytest
import re
import time
import subprocess
import requests
from pathlib import Path
from tests.lib.common_test_utils import (
GiteaAPIClient,
mock_build_result,
)
# =============================================================================
# TEST CASES
# =============================================================================
def test_pr_workflow_succeeded(gitea_env, mock_build_result):
"""End-to-end test for a successful PR workflow."""
diff = "diff --git a/test.txt b/test.txt\nnew file mode 100644\nindex 0000000..e69de29\n"
pr = gitea_env.create_gitea_pr("pool/pkgA", diff, "Test PR - should succeed")
initial_pr_number = pr["number"]
compose_dir = Path(__file__).parent.parent
forwarded_pr_number = None
print(
f"Polling pool/pkgA PR #{initial_pr_number} timeline for forwarded PR event..."
)
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("pool/pkgA", initial_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"products/SLFO/pulls/(\d+)", url_to_check)
if match:
forwarded_pr_number = match.group(1)
break
if forwarded_pr_number:
break
assert (
forwarded_pr_number is not None
), "Workflow bot did not create a pull_ref event on the timeline."
print(f"Found forwarded PR: products/SLFO #{forwarded_pr_number}")
print(f"Polling products/SLFO PR #{forwarded_pr_number} for reviewer assignment...")
reviewer_added = False
for _ in range(15):
time.sleep(1)
pr_details = gitea_env.get_pr_details("products/SLFO", forwarded_pr_number)
if any(
r.get("login") == "autogits_obs_staging_bot"
for r in pr_details.get("requested_reviewers", [])
):
reviewer_added = True
break
assert reviewer_added, "Staging bot was not added as a reviewer."
print("Staging bot has been added as a reviewer.")
mock_build_result(package_name="pkgA", code="succeeded")
print("Restarting obs-staging-bot...")
subprocess.run(
["podman-compose", "restart", "obs-staging-bot"],
cwd=compose_dir,
check=True,
capture_output=True,
)
print(f"Polling products/SLFO PR #{forwarded_pr_number} for final status...")
status_comment_found = False
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("products/SLFO", forwarded_pr_number)
for event in timeline_events:
print(event.get("body", "not a body"))
if event.get("body") and "successful" in event["body"]:
status_comment_found = True
break
if status_comment_found:
break
assert status_comment_found, "Staging bot did not post a 'successful' comment."
def test_pr_workflow_failed(gitea_env, mock_build_result):
"""End-to-end test for a failed PR workflow."""
diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n"
pr = gitea_env.create_gitea_pr("pool/pkgA", diff, "Test PR - should fail")
initial_pr_number = pr["number"]
compose_dir = Path(__file__).parent.parent
forwarded_pr_number = None
print(
f"Polling pool/pkgA PR #{initial_pr_number} timeline for forwarded PR event..."
)
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("pool/pkgA", initial_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"products/SLFO/pulls/(\d+)", url_to_check)
if match:
forwarded_pr_number = match.group(1)
break
if forwarded_pr_number:
break
assert (
forwarded_pr_number is not None
), "Workflow bot did not create a pull_ref event on the timeline."
print(f"Found forwarded PR: products/SLFO #{forwarded_pr_number}")
print(f"Polling products/SLFO PR #{forwarded_pr_number} for reviewer assignment...")
reviewer_added = False
for _ in range(15):
time.sleep(1)
pr_details = gitea_env.get_pr_details("products/SLFO", forwarded_pr_number)
if any(
r.get("login") == "autogits_obs_staging_bot"
for r in pr_details.get("requested_reviewers", [])
):
reviewer_added = True
break
assert reviewer_added, "Staging bot was not added as a reviewer."
print("Staging bot has been added as a reviewer.")
mock_build_result(package_name="pkgA", code="failed")
print("Restarting obs-staging-bot...")
subprocess.run(
["podman-compose", "restart", "obs-staging-bot"],
cwd=compose_dir,
check=True,
capture_output=True,
)
print(f"Polling products/SLFO PR #{forwarded_pr_number} for final status...")
status_comment_found = False
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("products/SLFO", forwarded_pr_number)
for event in timeline_events:
if event.get("body") and "failed" in event["body"]:
status_comment_found = True
break
if status_comment_found:
break
assert status_comment_found, "Staging bot did not post a 'failed' comment."

View File

@@ -0,0 +1,117 @@
import pytest
import re
import time
import subprocess
import requests
from pathlib import Path
from tests.lib.common_test_utils import (
GiteaAPIClient,
)
# =============================================================================
# TEST CASES
# =============================================================================
pytest.pr = None
pytest.pr_details = None
pytest.initial_pr_number = None
pytest.forwarded_pr_number = None
@pytest.mark.dependency()
def test_001_project_pr(gitea_env):
"""Forwarded PR correct title"""
diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n"
pytest.pr = gitea_env.create_gitea_pr("pool/pkgA", diff, "Test PR")
pytest.initial_pr_number = pytest.pr["number"]
time.sleep(5) # Give Gitea some time to process the PR and make the timeline available
compose_dir = Path(__file__).parent.parent
pytest.forwarded_pr_number = None
print(
f"Polling pool/pkgA PR #{pytest.initial_pr_number} timeline for forwarded PR event..."
)
# Instead of polling timeline, check if forwarded PR exists directly
for _ in range(20):
time.sleep(1)
timeline_events = gitea_env.get_timeline_events("pool/pkgA", pytest.initial_pr_number)
for event in timeline_events:
if event.get("type") == "pull_ref":
if not (ref_issue := event.get("ref_issue")):
continue
url_to_check = ref_issue.get("html_url", "")
match = re.search(r"products/SLFO/pulls/(\d+)", url_to_check)
if match:
pytest.forwarded_pr_number = match.group(1)
break
if pytest.forwarded_pr_number:
break
assert (
pytest.forwarded_pr_number is not None
), "Workflow bot did not create a forwarded PR."
pytest.pr_details = gitea_env.get_pr_details("products/SLFO", pytest.forwarded_pr_number)
assert (
pytest.pr_details["title"] == "Forwarded PRs: pkgA"
), "Forwarded PR correct title"
@pytest.mark.dependency(depends=["test_001_project_pr"])
def test_002_updated_project_pr(gitea_env):
"""Forwarded PR head is updated"""
diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100444\nindex 0000000..e69de21\n"
gitea_env.modify_gitea_pr("pool/pkgA", pytest.initial_pr_number, diff, "Tweaks")
sha_old = pytest.pr_details["head"]["sha"]
sha_changed = False
for _ in range(20):
time.sleep(1)
new_pr_details = gitea_env.get_pr_details("products/SLFO", pytest.forwarded_pr_number)
sha_new = new_pr_details["head"]["sha"]
if sha_new != sha_old:
print(f"Sha changed from {sha_old} to {sha_new}")
sha_changed = True
break
assert sha_changed, "Forwarded PR has sha updated"
@pytest.mark.dependency(depends=["test_001_project_pr"])
def test_003_wip(gitea_env):
"""WIP flag set for PR"""
# 1. set WIP flag in PR f"pool/pkgA#{pytest.initial_pr_number}"
initial_pr_details = gitea_env.get_pr_details("pool/pkgA", pytest.initial_pr_number)
wip_title = "WIP: " + initial_pr_details["title"]
gitea_env.update_gitea_pr_properties("pool/pkgA", pytest.initial_pr_number, title=wip_title)
# 2. in loop check whether WIP flag is set for PR f"products/SLFO #{pytest.forwarded_pr_number}"
wip_flag_set = False
for _ in range(20):
time.sleep(1)
forwarded_pr_details = gitea_env.get_pr_details(
"products/SLFO", pytest.forwarded_pr_number
)
if "WIP: " in forwarded_pr_details["title"]:
wip_flag_set = True
break
assert wip_flag_set, "WIP flag was not set in the forwarded PR."
# Remove WIP flag from PR f"pool/pkgA#{pytest.initial_pr_number}"
initial_pr_details = gitea_env.get_pr_details("pool/pkgA", pytest.initial_pr_number)
non_wip_title = initial_pr_details["title"].replace("WIP: ", "")
gitea_env.update_gitea_pr_properties(
"pool/pkgA", pytest.initial_pr_number, title=non_wip_title
)
# In loop check whether WIP flag is removed for PR f"products/SLFO #{pytest.forwarded_pr_number}"
wip_flag_removed = False
for _ in range(20):
time.sleep(1)
forwarded_pr_details = gitea_env.get_pr_details(
"products/SLFO", pytest.forwarded_pr_number
)
if "WIP: " not in forwarded_pr_details["title"]:
wip_flag_removed = True
break
assert wip_flag_removed, "WIP flag was not removed from the forwarded PR."

View File

@@ -0,0 +1 @@
Dockerfile.package

View File

@@ -0,0 +1,17 @@
# Use the same base image as the Gitea container
FROM registry.suse.com/bci/bci-base:15.7
# Add the custom CA to the trust store
COPY integration/rabbitmq-config/certs/cert.pem /usr/share/pki/trust/anchors/gitea-rabbitmq-ca.crt
RUN update-ca-certificates
# Install git and ssh
RUN zypper -n in git-core openssh-clients binutils
# Copy the pre-built binary into the container
COPY workflow-pr/workflow-pr /usr/local/bin/workflow-pr
COPY integration/workflow-pr/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +4755 /usr/local/bin/entrypoint.sh
# Set the entrypoint for the container
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -0,0 +1,18 @@
# Use the same base image as the Gitea container
FROM registry.suse.com/bci/bci-base:15.7
# Add the custom CA to the trust store
COPY rabbitmq-config/certs/cert.pem /usr/share/pki/trust/anchors/gitea-rabbitmq-ca.crt
RUN update-ca-certificates
RUN zypper ar -f http://download.opensuse.org/repositories/devel:/Factory:/git-workflow/15.7/devel:Factory:git-workflow.repo
RUN zypper --gpg-auto-import-keys ref
# Install git and ssh
RUN zypper -n in git-core openssh-clients autogits-workflow-pr binutils
COPY integration/workflow-pr/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +4755 /usr/local/bin/entrypoint.sh
# Set the entrypoint for the container
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -0,0 +1,66 @@
#!/bin/bash
TOKEN_FILE="/var/lib/gitea/workflow-pr.token"
# Wait for the token file to be created by the gitea setup script
echo "Waiting for $TOKEN_FILE..."
while [ ! -s "$TOKEN_FILE" ]; do
sleep 2
done
# Read token and trim whitespace/newlines
GITEA_TOKEN=$(cat "$TOKEN_FILE" | tr -d '\n\r ' )
if [ -z "$GITEA_TOKEN" ]; then
echo "Error: Token file $TOKEN_FILE is empty after trimming."
exit 1
fi
export GITEA_TOKEN
echo "GITEA_TOKEN exported (length: ${#GITEA_TOKEN})"
# Wait for the dummy data to be created by the gitea setup script
echo "Waiting for workflow.config in products/SLFO..."
API_URL="http://gitea-test:3000/api/v1/repos/products/SLFO/contents/workflow.config"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$API_URL")
while [ "$HTTP_STATUS" != "200" ]; do
echo "workflow.config not found yet (HTTP Status: $HTTP_STATUS). Retrying in 5s..."
sleep 5
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$API_URL")
done
# Wait for the shared SSH key to be generated by the gitea setup script
echo "Waiting for /var/lib/gitea/ssh-keys/id_ed25519..."
while [ ! -f /var/lib/gitea/ssh-keys/id_ed25519 ]; do
sleep 2
done
export AUTOGITS_IDENTITY_FILE="/root/.ssh/id_ed25519"
# Pre-populate known_hosts with Gitea's SSH host key
echo "Preparing SSH environment in /root/.ssh..."
mkdir -p /root/.ssh
chmod 700 /root/.ssh
# Copy the private key to the standard location and set permissions
cp /var/lib/gitea/ssh-keys/id_ed25519 /root/.ssh/id_ed25519
chmod 600 /root/.ssh/id_ed25519
echo "Scanning Gitea SSH host key..."
# We try multiple times because Gitea might still be starting its SSH server
for i in {1..10}; do
ssh-keyscan -p 3022 gitea-test >> /root/.ssh/known_hosts 2>/dev/null && break
echo "Retrying ssh-keyscan in 2s..."
sleep 2
done
chmod 644 /root/.ssh/known_hosts
exe=$(which workflow-pr)
exe=${exe:-/usr/local/bin/workflow-pr}
package=$(rpm -qa | grep autogits-workflow-pr) || :
echo "!!!!!!!!!!!!!!!! using binary $exe; installed package: $package"
which strings > /dev/null 2>&1 && strings "$exe" | grep -A 2 vcs.revision= | head -4 || :
exec "$exe" "$@"

View File

@@ -0,0 +1,3 @@
[
"products/SLFO#main"
]

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
@@ -21,22 +25,56 @@ 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
It's a JSON file with following syntax:
```
```json
{
"ObsProject": "home:foo:project",
"StagingProject": "home:foo:project:staging",
"QA": [
{
"Name": "ProjectBuild",
"Origin": "home:foo:product:images"
}
]
"ObsProject": "SUSE:SLFO:1.2",
"StagingProject": "SUSE:SLFO:1.2:PullRequest",
"QA": [
{
"Name": "SLES",
"Origin": "SUSE:SLFO:Products:SLES:16.0",
"Label": "BootstrapRing",
"BuildDisableRepos": ["product"]
}
]
}
```
* ObsProject: (**required**) Project where the base project is built. Builds in this project will be used to compare to builds based on sources from the PR
* StagingProject: template project that will be used as template for the staging project. Omitting this will use the ObsProject repositories to create the staging. Staging project will be created under the template, or in the bot's home directory if not specified.
* QA: set of projects to build ontop of the binaries built in staging.
| 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 > Label* | Setup the project only when the given gitea label is set on pull request | 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.
* QA projects can build on each other. In this case it is important that the order to setup is correct
in the staging.config file.
* Based on Label settings QA projects can get created or removed. The staging bot is also checking that these
projects build successfully.
* It is possible to include the sources from the staging project also in the QA project. Define a template using
a project link pointing to the project defined as "StagingProject". You must *not* use scmsync directly in the
same project then, but you can use it indirectly via a second project link

View File

@@ -19,6 +19,7 @@ package main
*/
import (
"bufio"
"encoding/xml"
"errors"
"flag"
@@ -109,161 +110,110 @@ const (
BuildStatusSummaryUnknown = 4
)
func ProcessBuildStatus(project, refProject *common.BuildResultList) BuildStatusSummary {
if _, finished := refProject.BuildResultSummary(); !finished {
common.LogDebug("refProject not finished building??")
return BuildStatusSummaryUnknown
}
type DisableFlag struct {
XMLName string `xml:"disable"`
Name string `xml:"repository,attr"`
}
func ProcessBuildStatus(project *common.BuildResultList) BuildStatusSummary {
if _, finished := project.BuildResultSummary(); !finished {
common.LogDebug("Still building...")
return BuildStatusSummaryBuilding
}
// the repositories should be setup equally between the projects. We
// need to verify that packages that are building in `refProject` are not
// failing in the `project`
BuildResultSorter := func(a, b *common.BuildResult) int {
if c := strings.Compare(a.Repository, b.Repository); c != 0 {
return c
}
if c := strings.Compare(a.Arch, b.Arch); c != 0 {
return c
}
panic("Should not happen -- BuiltResultSorter equal repos?")
}
slices.SortFunc(project.Result, BuildResultSorter)
if refProject == nil {
// just return if buid finished and have some successes, since new package
common.LogInfo("New package. Only need some success...")
SomeSuccess := false
for i := 0; i < len(project.Result); i++ {
repoRes := project.Result[i]
repoResStatus, ok := common.ObsRepoStatusDetails[repoRes.Code]
if !ok {
common.LogDebug("cannot find code:", repoRes.Code)
return BuildStatusSummaryUnknown
}
if !repoResStatus.Finished {
return BuildStatusSummaryBuilding
}
for _, pkg := range repoRes.Status {
pkgStatus, ok := common.ObsBuildStatusDetails[pkg.Code]
if !ok {
common.LogInfo("Unknown package build status:", pkg.Code, "for", pkg.Package)
common.LogDebug("Details:", pkg.Details)
}
if pkgStatus.Success {
SomeSuccess = true
}
}
}
if SomeSuccess {
return BuildStatusSummarySuccess
}
return BuildStatusSummaryFailed
}
slices.SortFunc(refProject.Result, BuildResultSorter)
common.LogDebug("comparing results", len(project.Result), "vs. ref", len(refProject.Result))
SomeSuccess := false
common.LogDebug("build results", len(project.Result))
for i := 0; i < len(project.Result); i++ {
common.LogDebug("searching for", project.Result[i].Repository, "/", project.Result[i].Arch)
j := 0
found:
for ; j < len(refProject.Result); j++ {
if project.Result[i].Repository != refProject.Result[j].Repository ||
project.Result[i].Arch != refProject.Result[j].Arch {
continue
}
for j := 0; j < len(project.Result); j++ {
common.LogDebug(" found match for @ idx:", j)
res, success := ProcessRepoBuildStatus(project.Result[i].Status, refProject.Result[j].Status)
res := ProcessRepoBuildStatus(project.Result[i].Status)
switch res {
case BuildStatusSummarySuccess:
SomeSuccess = SomeSuccess || success
break found
case BuildStatusSummaryFailed:
return BuildStatusSummaryFailed
default:
return res
}
}
if j >= len(refProject.Result) {
common.LogDebug("Cannot find results...")
common.LogDebug(project.Result[i])
common.LogDebug(refProject.Result)
return BuildStatusSummaryUnknown
}
}
if SomeSuccess {
return BuildStatusSummarySuccess
}
return BuildStatusSummaryFailed
return BuildStatusSummarySuccess
}
func ProcessRepoBuildStatus(results, ref []*common.PackageBuildStatus) (status BuildStatusSummary, SomeSuccess bool) {
PackageBuildStatusSorter := func(a, b *common.PackageBuildStatus) int {
return strings.Compare(a.Package, b.Package)
}
func ProcessRepoBuildStatus(results []*common.PackageBuildStatus) (status BuildStatusSummary) {
PackageBuildStatusSorter := func(a, b *common.PackageBuildStatus) int {
return strings.Compare(a.Package, b.Package)
}
common.LogDebug("******** REF: ")
data, _ := xml.MarshalIndent(ref, "", " ")
common.LogDebug(string(data))
common.LogDebug("******* RESULTS: ")
data, _ = xml.MarshalIndent(results, "", " ")
data, _ := xml.MarshalIndent(results, "", " ")
common.LogDebug(string(data))
common.LogDebug("*******")
// compare build result
slices.SortFunc(results, PackageBuildStatusSorter)
slices.SortFunc(ref, PackageBuildStatusSorter)
j := 0
SomeSuccess = false
for i := 0; i < len(results); i++ {
res, ok := common.ObsBuildStatusDetails[results[i].Code]
if !ok {
common.LogInfo("unknown package result code:", results[i].Code, "for package:", results[i].Package)
return BuildStatusSummaryUnknown, SomeSuccess
return BuildStatusSummaryUnknown
}
if !res.Finished {
return BuildStatusSummaryBuilding, SomeSuccess
return BuildStatusSummaryBuilding
}
if !res.Success {
// not failed if reference project also failed for same package here
for ; j < len(results) && strings.Compare(results[i].Package, ref[j].Package) < 0; j++ {
}
if j < len(results) && results[i].Package == ref[j].Package {
refRes, ok := common.ObsBuildStatusDetails[ref[j].Code]
if !ok {
common.LogInfo("unknown ref package result code:", ref[j].Code, "package:", ref[j].Package)
return BuildStatusSummaryUnknown, SomeSuccess
}
if !refRes.Finished {
common.LogDebug("Not finished building in reference project?")
}
if refRes.Success {
return BuildStatusSummaryFailed, SomeSuccess
}
}
} else {
SomeSuccess = true
return BuildStatusSummaryFailed
}
}
return BuildStatusSummarySuccess, SomeSuccess
return BuildStatusSummarySuccess
}
func GetPackageBuildStatus(project *common.BuildResultList, packageName string) (bool, BuildStatusSummary) {
var packageStatuses []*common.PackageBuildStatus
// Collect all statuses for the package
for _, result := range project.Result {
for _, pkgStatus := range result.Status {
if pkgStatus.Package == packageName {
packageStatuses = append(packageStatuses, pkgStatus)
}
}
}
if len(packageStatuses) == 0 {
return true, BuildStatusSummaryUnknown // true for 'missing'
}
// Check for any failures
for _, pkgStatus := range packageStatuses {
res, ok := common.ObsBuildStatusDetails[pkgStatus.Code]
if !ok {
common.LogInfo("unknown package result code:", pkgStatus.Code, "for package:", pkgStatus.Package)
return false, BuildStatusSummaryUnknown
}
if !res.Success {
return false, BuildStatusSummaryFailed
}
}
// Check for any unfinished builds
for _, pkgStatus := range packageStatuses {
res, _ := common.ObsBuildStatusDetails[pkgStatus.Code]
// 'ok' is already checked in the loop above
if !res.Finished {
return false, BuildStatusSummaryBuilding
}
}
// If we got here, all are finished and successful
return false, BuildStatusSummarySuccess
}
func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingPrj, buildPrj string, stagingMasterPrj string) (*common.ProjectMeta, error) {
@@ -322,9 +272,9 @@ func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullReque
common.LogError("error fetching project meta for", buildPrj, ". Err:", err)
return nil, err
}
common.LogInfo("Meta: ", meta)
// generate new project with paths pointinig back to original repos
// disable publishing
meta.Name = stagingPrj
meta.Description = fmt.Sprintf(`Pull request build job PR#%d to branch %s of %s/%s`,
@@ -339,7 +289,10 @@ func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullReque
urlPkg := make([]string, 0, len(modifiedOrNew))
for _, pkg := range modifiedOrNew {
urlPkg = append(urlPkg, "onlybuild="+url.QueryEscape(pkg))
// FIXME: skip manifest subdirectories itself
// strip any leading directory name and just hand over last directory as package name
onlybuilds := strings.Split(pkg, "/")
urlPkg = append(urlPkg, "onlybuild="+url.QueryEscape(onlybuilds[len(onlybuilds)-1]))
}
meta.ScmSync = pr.Head.Repo.CloneURL + "?" + strings.Join(urlPkg, "&") + "#" + pr.Head.Sha
if len(meta.ScmSync) >= 65535 {
@@ -377,14 +330,16 @@ 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")
common.LogDebug("reading templateProject ", templateProject)
templateMeta, err := ObsClient.GetProjectMeta(templateProject)
if err != nil {
common.LogError("error fetching template project meta for", templateProject, ":", err)
return err
}
// patch baseMeta to become the new project
common.LogDebug("upcoming project name ", stagingProject, ":", subProjectName)
templateMeta.Name = stagingProject + ":" + subProjectName
// freeze tag for now
if len(templateMeta.ScmSync) > 0 {
@@ -407,20 +362,65 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git
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)
}
}
// include sources from submission project when link points to staging project
for idx, l := range templateMeta.Link {
if l.Project == stagingConfig.StagingProject {
templateMeta.Link[idx].Project = stagingProject
}
}
// Cleanup ReleaseTarget and modify affected path entries
for idx, r := range templateMeta.Repositories {
templateMeta.Repositories[idx].ReleaseTargets = nil
for pidx, path := range r.Paths {
// Check for path building against code stream
common.LogDebug(" checking in ", templateMeta.Name)
common.LogDebug(" stagingProject ", stagingProject)
common.LogDebug(" checking for ", templateMeta.Repositories[idx].Paths[pidx].Project)
common.LogDebug(" path.Project ", path.Project)
common.LogDebug(" stagingConfig.ObsProject ", stagingConfig.ObsProject)
common.LogDebug(" stagingConfig.StagingProject ", stagingConfig.StagingProject)
common.LogDebug(" templateProject ", templateProject)
if path.Project == stagingConfig.ObsProject {
templateMeta.Repositories[idx].Paths[pidx].Project = stagingProject
}
} else
// Check for path building against a repo in template project itself
if path.Project == templateProject {
templateMeta.Repositories[idx].Paths[pidx].Project = templateMeta.Name
} else
// Check for path prefixes against a template project inside of template project area
if strings.HasPrefix(path.Project, stagingConfig.StagingProject + ":") {
newProjectName := stagingProject
// find project name
for _, setup := range stagingConfig.QA {
if setup.Origin == path.Project {
common.LogDebug(" Match:", setup.Origin)
newProjectName = newProjectName + ":" + setup.Name
common.LogDebug(" New:", newProjectName)
break
}
}
templateMeta.Repositories[idx].Paths[pidx].Project = newProjectName
common.LogDebug(" Matched prefix")
}
common.LogDebug(" Path using project ", templateMeta.Repositories[idx].Paths[pidx].Project)
}
}
@@ -428,6 +428,8 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git
err = ObsClient.SetProjectMeta(templateMeta)
if err != nil {
common.LogError("cannot create project:", templateMeta.Name, err)
x, _ := xml.MarshalIndent(templateMeta, "", " ")
common.LogError(string(x))
return err
}
} else {
@@ -534,7 +536,7 @@ func FetchOurLatestActionableReview(gitea common.Gitea, org, repo string, id int
}
func ParseNotificationToPR(thread *models.NotificationThread) (org string, repo string, num int64, err error) {
rx := regexp.MustCompile(`^https://src\.(?:open)?suse\.(?:org|de)/api/v\d+/repos/(?<org>[-_a-zA-Z0-9]+)/(?<project>[-_a-zA-Z0-9]+)/issues/(?<num>[0-9]+)$`)
rx := regexp.MustCompile(`^.*/api/v\d+/repos/(?<org>[-_a-zA-Z0-9]+)/(?<project>[-_a-zA-Z0-9]+)/issues/(?<num>[0-9]+)$`)
notification := thread.Subject
match := rx.FindStringSubmatch(notification.URL)
if match == nil {
@@ -683,6 +685,64 @@ func SetStatus(gitea common.Gitea, org, repo, hash string, status *models.Commit
return err
}
func commentOnPackagePR(gitea common.Gitea, org string, repo string, prNum int64, msg string) {
if IsDryRun {
common.LogInfo("Would comment on package PR %s/%s#%d: %s", org, repo, prNum, msg)
return
}
pr, err := gitea.GetPullRequest(org, repo, prNum)
if err != nil {
common.LogError("Failed to get package PR %s/%s#%d: %v", org, repo, prNum, err)
return
}
err = gitea.AddComment(pr, msg)
if err != nil {
common.LogError("Failed to comment on package PR %s/%s#%d: %v", org, repo, prNum, err)
}
}
// Create and remove QA projects
func ProcessQaProjects(stagingConfig *common.StagingConfig, git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingProject string) []string {
usedQAprojects := make([]string, 0)
prLabelNames := make(map[string]int)
for _, label := range pr.Labels {
prLabelNames[label.Name] = 1
}
msg := ""
for _, setup := range stagingConfig.QA {
QAproject := stagingProject + ":" + setup.Name
if len(setup.Label) > 0 {
if _, ok := prLabelNames[setup.Label]; !ok {
if !IsDryRun {
// blindly remove, will fail when not existing
ObsClient.DeleteProject(QAproject)
}
common.LogInfo("QA project ", setup.Name, "has no matching Label")
continue
}
}
usedQAprojects = append(usedQAprojects, QAproject)
// check for existens first, no error, but no meta is a 404
if meta, err := ObsClient.GetProjectMeta(QAproject); meta == nil && err == nil {
common.LogInfo("Create QA project ", QAproject)
CreateQASubProject(stagingConfig, git, gitea, pr,
stagingProject,
setup.Origin,
setup.Name,
setup.BuildDisableRepos)
msg = msg + "QA Project added: " + ObsWebHost + "/project/show/" +
QAproject + "\n"
}
}
if len(msg) > 1 {
gitea.AddComment(pr, msg)
}
return usedQAprojects
}
func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, error) {
dir, err := os.MkdirTemp(os.TempDir(), BotName)
common.PanicOnError(err)
@@ -760,11 +820,12 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
meta, err := ObsClient.GetProjectMeta(stagingConfig.ObsProject)
if err != nil || meta == nil {
common.LogError("Cannot find reference project meta:", stagingConfig.ObsProject, err)
if !IsDryRun {
if !IsDryRun && err == nil {
common.LogError("Reference project is absent:", stagingConfig.ObsProject, err)
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot fetch reference project meta")
return true, err
}
return true, nil
return true, err
}
if metaUrl, err := url.Parse(meta.ScmSync); err != nil {
@@ -796,6 +857,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
l := len(stagingConfig.ObsProject)
if l >= len(stagingConfig.StagingProject) || stagingConfig.ObsProject != stagingConfig.StagingProject[0:l] {
common.LogError("StagingProject (", stagingConfig.StagingProject, ") is not child of target project", stagingConfig.ObsProject)
return true, nil
}
}
@@ -914,35 +976,49 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
msg = "Build is started in " + ObsWebHost + "/project/show/" +
stagingProject + " .\n"
if len(stagingConfig.QA) > 0 {
msg = msg + "\nAdditional QA builds: \n"
}
SetStatus(gitea, org, repo, pr.Head.Sha, status)
for _, setup := range stagingConfig.QA {
CreateQASubProject(stagingConfig, git, gitea, pr,
stagingProject,
setup.Origin,
setup.Name)
msg = msg + ObsWebHost + "/project/show/" +
stagingProject + ":" + setup.Name + "\n"
}
}
if change != RequestModificationNoChange && !IsDryRun {
gitea.AddComment(pr, msg)
}
baseResult, err := ObsClient.LastBuildResults(stagingConfig.ObsProject, modifiedPackages...)
if err != nil {
common.LogError("failed fetching ref project status for", stagingConfig.ObsProject, ":", err)
}
stagingResult, err := ObsClient.BuildStatus(stagingProject)
if err != nil {
common.LogError("failed fetching stage project status for", stagingProject, ":", err)
}
buildStatus := ProcessBuildStatus(stagingResult, baseResult)
_, packagePRs := common.ExtractDescriptionAndPRs(bufio.NewScanner(strings.NewReader(pr.Body)))
// always update QA projects because Labels can change
qaProjects := ProcessQaProjects(stagingConfig, git, gitea, pr, stagingProject)
done := false
switch buildStatus {
overallBuildStatus := ProcessBuildStatus(stagingResult)
commentSuffix := ""
if len(qaProjects) > 0 && overallBuildStatus == BuildStatusSummarySuccess {
seperator := " in "
for _, qaProject := range qaProjects {
qaResult, err := ObsClient.BuildStatus(qaProject)
if err != nil {
common.LogError("failed fetching stage project status for", qaProject, ":", err)
}
qaBuildStatus := ProcessBuildStatus(qaResult)
if qaBuildStatus != BuildStatusSummarySuccess {
// either still building or in failed state
overallBuildStatus = qaBuildStatus
commentSuffix = commentSuffix + seperator + qaProject
seperator = ", "
}
if qaBuildStatus == BuildStatusSummaryFailed {
// main project was successful, but QA project, adapt the link to QA project
// and change commit state to fail
status.Status = common.CommitStatus_Fail
status.TargetURL = ObsWebHost + "/project/show/" + qaProject
SetStatus(gitea, org, repo, pr.Head.Sha, status)
}
}
}
switch overallBuildStatus {
case BuildStatusSummarySuccess:
status.Status = common.CommitStatus_Success
done = true
@@ -962,7 +1038,44 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
}
}
}
common.LogInfo("Build status:", buildStatus)
if overallBuildStatus == BuildStatusSummarySuccess || overallBuildStatus == BuildStatusSummaryFailed {
// avoid commenting while build is in progress
missingPkgs := []string{}
for _, packagePR := range packagePRs {
missing, packageBuildStatus := GetPackageBuildStatus(stagingResult, packagePR.Repo)
if missing {
missingPkgs = append(missingPkgs, packagePR.Repo)
continue
}
var msg string
switch packageBuildStatus {
case BuildStatusSummarySuccess:
msg = fmt.Sprintf("Build successful, for more information go in %s/project/show/%s.\n", ObsWebHost, stagingProject)
case BuildStatusSummaryFailed:
msg = fmt.Sprintf("Build failed, for more information go in %s/project/show/%s.\n", ObsWebHost, stagingProject)
default:
continue
}
commentOnPackagePR(gitea, packagePR.Org, packagePR.Repo, packagePR.Num, msg)
}
if len(missingPkgs) > 0 {
overallBuildStatus = BuildStatusSummaryFailed
msg := "The following packages were not found in the staging project:\n"
for _, pkg := range missingPkgs {
msg = msg + " - " + pkg + "\n"
}
common.LogInfo(msg)
err := gitea.AddComment(pr, msg)
if err != nil {
common.LogError(err)
}
}
}
common.LogInfo("Build status:", overallBuildStatus)
if !IsDryRun {
if err = SetStatus(gitea, org, repo, pr.Head.Sha, status); err != nil {
return false, err
@@ -1027,6 +1140,7 @@ func PollWorkNotifications(giteaUrl string) {
var ListPullNotificationsOnly bool
var GiteaUrl string
var ObsApiHost string
var ObsWebHost string
var IsDryRun bool
var ProcessPROnly string
@@ -1049,8 +1163,8 @@ func main() {
flag.BoolVar(&ListPullNotificationsOnly, "list-notifications-only", false, "Only lists notifications without acting on them")
ProcessPROnly := flag.String("pr", "", "Process only specific PR and ignore the rest. Use for debugging")
buildRoot := flag.String("build-root", "", "Default build location for staging projects. Default is bot's home project")
flag.StringVar(&GiteaUrl, "gitea-url", "https://src.opensuse.org", "Gitea instance")
obsApiHost := flag.String("obs", "https://api.opensuse.org", "API for OBS instance")
flag.StringVar(&GiteaUrl, "gitea-url", "", "Gitea instance")
flag.StringVar(&ObsApiHost, "obs", "", "API for OBS instance")
flag.StringVar(&ObsWebHost, "obs-web", "", "Web OBS instance, if not derived from the obs config")
flag.BoolVar(&IsDryRun, "dry", false, "Dry-run, don't actually create any build projects or review changes")
debug := flag.Bool("debug", false, "Turns on debug logging")
@@ -1062,19 +1176,34 @@ func main() {
common.SetLoggingLevel(common.LogLevelInfo)
}
if len(GiteaUrl) == 0 {
GiteaUrl = os.Getenv(common.GiteaHostEnv)
}
if len(GiteaUrl) == 0 {
GiteaUrl = "https://src.opensuse.org"
}
if len(ObsApiHost) == 0 {
ObsApiHost = os.Getenv(common.ObsApiEnv)
}
if len(ObsApiHost) == 0 {
ObsApiHost = "https://api.opensuse.org"
}
if len(ObsWebHost) == 0 {
ObsWebHost = ObsWebHostFromApiHost(*obsApiHost)
ObsWebHost = os.Getenv(common.ObsWebEnv)
}
if len(ObsWebHost) == 0 {
ObsWebHost = "https://build.opensuse.org"
}
common.LogDebug("OBS Gitea Host:", GiteaUrl)
common.LogDebug("OBS Web Host:", ObsWebHost)
common.LogDebug("OBS API Host:", *obsApiHost)
common.LogDebug("OBS API Host:", ObsApiHost)
common.PanicOnErrorWithMsg(common.RequireGiteaSecretToken(), "Cannot find GITEA_TOKEN")
common.PanicOnErrorWithMsg(common.RequireObsSecretToken(), "Cannot find OBS_USER and OBS_PASSWORD")
var err error
if ObsClient, err = common.NewObsClient(*obsApiHost); err != nil {
if ObsClient, err = common.NewObsClient(ObsApiHost); err != nil {
log.Error(err)
return
}

View File

@@ -1,7 +1,10 @@
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:
@@ -17,19 +20,31 @@ 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
-------
@@ -42,3 +57,4 @@ Default parameters can be changed by env variables
| `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

@@ -105,10 +105,10 @@ func ProjectStatusSummarySvg(res []*common.BuildResult) []byte {
func LinkToBuildlog(R *common.BuildResult, S *common.PackageBuildStatus) string {
if R != nil && S != nil {
switch S.Code {
case "succeeded", "failed", "building":
//switch S.Code {
//case "succeeded", "failed", "building":
return "/buildlog/" + url.PathEscape(R.Project) + "/" + url.PathEscape(S.Package) + "/" + url.PathEscape(R.Repository) + "/" + url.PathEscape(R.Arch)
}
//}
}
return ""
}
@@ -170,6 +170,7 @@ func BuildStatusSvg(repo *common.BuildResult, status *common.PackageBuildStatus)
buildStatus, ok := common.ObsBuildStatusDetails[status.Code]
if !ok {
buildStatus = common.ObsBuildStatusDetails["error"]
common.LogError("Cannot find detail for status.Code", status.Code)
}
fillColor := "#480" // orange
textColor := "#888"

View File

@@ -1,6 +1,9 @@
package main
import (
"compress/bzip2"
"encoding/json"
"io"
"os"
"testing"
@@ -82,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

@@ -6,6 +6,7 @@ import (
"html"
"net/url"
"slices"
"strings"
)
type SvgWriter struct {
@@ -133,7 +134,7 @@ func (svg *SvgWriter) WritePackageStatus(loglink, arch, status, detail string) {
}
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(repo) + "=1")
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
}

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

@@ -6,9 +6,17 @@ After=network-online.target
Type=exec
ExecStart=/usr/bin/workflow-direct
EnvironmentFile=-/etc/default/%i/workflow-direct.env
DynamicUser=yes
#DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
# DynamicUser does not work as we cannot seem to be able to put SSH keyfiles into the temp home that are readable by SSH
# Also, systemd override is needed away to assign User to run this. This should be dependent per instance.
ProtectHome=no
PrivateTmp=yes
# 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

@@ -0,0 +1,23 @@
[Unit]
Description=WorkflowPR git bot for %i
After=network-online.target
[Service]
Type=exec
ExecStart=/usr/bin/workflow-pr
EnvironmentFile=-/etc/default/%i/workflow-pr.env
#DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
# DynamicUser does not work as we cannot seem to be able to put SSH keyfiles into the temp home that are readable by SSH
# Also, systemd override is needed away to assign User to run this. This should be dependent per instance.
ProtectHome=no
PrivateTmp=yes
# 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

@@ -0,0 +1,98 @@
package main
import (
"flag"
"fmt"
"os"
"slices"
"src.opensuse.org/autogits/common"
)
func WriteNewMaintainershipFile(m *common.MaintainershipMap, filename string) {
f, err := os.Create(filename + ".new")
common.PanicOnError(err)
common.PanicOnError(m.WriteMaintainershipFile(f))
common.PanicOnError(f.Sync())
common.PanicOnError(f.Close())
common.PanicOnError(os.Rename(filename+".new", filename))
}
func run() error {
pkg := flag.String("package", "", "Package to modify")
rm := flag.Bool("rm", false, "Remove maintainer from package")
add := flag.Bool("add", false, "Add maintainer to package")
lint := flag.Bool("lint-only", false, "Reformat entire _maintainership.json only")
flag.Parse()
if (*add == *rm) && !*lint {
return fmt.Errorf("Need to either add or remove a maintainer, or lint")
}
filename := common.MaintainershipFile
if *lint {
if len(flag.Args()) > 0 {
filename = flag.Arg(0)
}
}
data, err := os.ReadFile(filename)
if os.IsNotExist(err) {
return err
}
if err != nil {
return err
}
m, err := common.ParseMaintainershipData(data)
if err != nil {
return fmt.Errorf("Failed to parse JSON: %w", err)
}
if *lint {
m.Raw = nil // forces a rewrite
} else {
users := flag.Args()
if len(users) > 0 {
maintainers, ok := m.Data[*pkg]
if !ok && !*add {
return fmt.Errorf("No package %s and not adding one.", *pkg)
}
if *add {
for _, u := range users {
if !slices.Contains(maintainers, u) {
maintainers = append(maintainers, u)
}
}
}
if *rm {
newMaintainers := make([]string, 0, len(maintainers))
for _, m := range maintainers {
if !slices.Contains(users, m) {
newMaintainers = append(newMaintainers, m)
}
}
maintainers = newMaintainers
}
if len(maintainers) > 0 {
slices.Sort(maintainers)
m.Data[*pkg] = maintainers
} else {
delete(m.Data, *pkg)
}
}
}
WriteNewMaintainershipFile(m, filename)
return nil
}
func main() {
if err := run(); err != nil {
common.LogError(err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,242 @@
package main
import (
"encoding/json"
"flag"
"os"
"os/exec"
"reflect"
"strings"
"testing"
"src.opensuse.org/autogits/common"
)
func TestMain(m *testing.M) {
if os.Getenv("BE_MAIN") == "1" {
main()
return
}
os.Exit(m.Run())
}
func TestRun(t *testing.T) {
tests := []struct {
name string
inData string
expectedOut string
params []string
expectedError string
isDir bool
}{
{
name: "add user to existing package",
inData: `{"pkg1": ["user1"]}`,
params: []string{"-package", "pkg1", "-add", "user2"},
expectedOut: `{"pkg1": ["user1", "user2"]}`,
},
{
name: "add user to new package",
inData: `{"pkg1": ["user1"]}`,
params: []string{"-package", "pkg2", "-add", "user2"},
expectedOut: `{"pkg1": ["user1"], "pkg2": ["user2"]}`,
},
{
name: "no-op with no users",
inData: `{"pkg1": ["user1"]}`,
params: []string{"-package", "pkg1", "-add"},
expectedOut: `{"pkg1": ["user1"]}`,
},
{
name: "add existing user",
inData: `{"pkg1": ["user1", "user2"]}`,
params: []string{"-package", "pkg1", "-add", "user2"},
expectedOut: `{"pkg1": ["user1", "user2"]}`,
},
{
name: "remove user from package",
inData: `{"pkg1": ["user1", "user2"]}`,
params: []string{"-package", "pkg1", "-rm", "user2"},
expectedOut: `{"pkg1": ["user1"]}`,
},
{
name: "remove last user from package",
inData: `{"pkg1": ["user1"]}`,
params: []string{"-package", "pkg1", "-rm", "user1"},
expectedOut: `{}`,
},
{
name: "remove non-existent user",
inData: `{"pkg1": ["user1"]}`,
params: []string{"-package", "pkg1", "-rm", "user2"},
expectedOut: `{"pkg1": ["user1"]}`,
},
{
name: "lint only unsorted",
inData: `{"pkg1": ["user2", "user1"]}`,
params: []string{"-lint-only"},
expectedOut: `{"pkg1": ["user1", "user2"]}`,
},
{
name: "lint only no changes",
inData: `{"pkg1": ["user1", "user2"]}`,
params: []string{"-lint-only"},
expectedOut: `{"pkg1": ["user1", "user2"]}`,
},
{
name: "no file",
params: []string{"-add"},
expectedError: "no such file or directory",
},
{
name: "invalid json",
inData: `{"pkg1": ["user1"`,
params: []string{"-add"},
expectedError: "Failed to parse JSON",
},
{
name: "add",
inData: `{"pkg1": ["user1", "user2"]}`,
params: []string{"-package", "pkg1", "-add", "user3"},
expectedOut: `{"pkg1": ["user1", "user2", "user3"]}`,
},
{
name: "lint specific file",
inData: `{"pkg1": ["user2", "user1"]}`,
params: []string{"-lint-only", "other.json"},
expectedOut: `{"pkg1": ["user1", "user2"]}`,
},
{
name: "add user to package when it was not there before",
inData: `{}`,
params: []string{"-package", "newpkg", "-add", "user1"},
expectedOut: `{"newpkg": ["user1"]}`,
},
{
name: "unreadable file (is a directory)",
isDir: true,
params: []string{"-rm"},
expectedError: "is a directory",
},
{
name: "remove user from non-existent package",
inData: `{"pkg1": ["user1"]}`,
params: []string{"-package", "pkg2", "-rm", "user2"},
expectedError: "No package pkg2 and not adding one.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
oldWd, _ := os.Getwd()
_ = os.Chdir(dir)
defer os.Chdir(oldWd)
targetFile := common.MaintainershipFile
if tt.name == "lint specific file" {
targetFile = "other.json"
}
if tt.isDir {
_ = os.Mkdir(targetFile, 0755)
} else if tt.inData != "" {
_ = os.WriteFile(targetFile, []byte(tt.inData), 0644)
}
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
os.Args = append([]string{"cmd"}, tt.params...)
err := run()
if tt.expectedError != "" {
if err == nil {
t.Fatalf("expected error containing %q, but got none", tt.expectedError)
}
if !strings.Contains(err.Error(), tt.expectedError) {
t.Fatalf("expected error containing %q, got %q", tt.expectedError, err.Error())
}
} else if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.expectedOut != "" {
data, _ := os.ReadFile(targetFile)
var got, expected map[string][]string
_ = json.Unmarshal(data, &got)
_ = json.Unmarshal([]byte(tt.expectedOut), &expected)
if len(got) == 0 && len(expected) == 0 {
return
}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("expected %v, got %v", expected, got)
}
}
})
}
}
func TestMainRecursive(t *testing.T) {
tests := []struct {
name string
inData string
expectedOut string
params []string
expectExit bool
}{
{
name: "test main() via recursive call",
inData: `{"pkg1": ["user1"]}`,
params: []string{"-package", "pkg1", "-add", "user2"},
expectedOut: `{"pkg1": ["user1", "user2"]}`,
},
{
name: "test main() failure",
params: []string{"-package", "pkg1"},
expectExit: true,
},
}
exe, _ := os.Executable()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
oldWd, _ := os.Getwd()
_ = os.Chdir(dir)
defer os.Chdir(oldWd)
if tt.inData != "" {
_ = os.WriteFile(common.MaintainershipFile, []byte(tt.inData), 0644)
}
cmd := exec.Command(exe, append([]string{"-test.run=None"}, tt.params...)...)
cmd.Env = append(os.Environ(), "BE_MAIN=1")
out, runErr := cmd.CombinedOutput()
if tt.expectExit {
if runErr == nil {
t.Fatalf("expected exit with error, but it succeeded")
}
return
}
if runErr != nil {
t.Fatalf("unexpected error: %v: %s", runErr, string(out))
}
if tt.expectedOut != "" {
data, _ := os.ReadFile(common.MaintainershipFile)
var got, expected map[string][]string
_ = json.Unmarshal(data, &got)
_ = json.Unmarshal([]byte(tt.expectedOut), &expected)
if !reflect.DeepEqual(got, expected) {
t.Fatalf("expected %v, got %v", expected, got)
}
}
})
}
}

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

@@ -90,6 +90,21 @@ func processConfiguredRepositoryAction(action *common.RepositoryWebhookEvent, co
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)
@@ -100,15 +115,16 @@ func processConfiguredRepositoryAction(action *common.RepositoryWebhookEvent, co
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" {
common.LogError(" - '%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.GitExecQuietOrPanic(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 != configBranch {
@@ -189,6 +205,7 @@ func processConfiguredPushAction(action *common.PushWebhookEvent, config *common
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)
@@ -198,24 +215,24 @@ func processConfiguredPushAction(action *common.PushWebhookEvent, config *common
}
if stat, err := os.Stat(filepath.Join(git.GetPath(), gitPrj, action.Repository.Name)); err != nil {
git.GitExecOrPanic(gitPrj, "submodule", "--quiet", "add", "--depth", "1", action.Repository.Clone_Url, action.Repository.Name)
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.GitExecQuietOrPanic(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, branch+":"+branch); err != nil {
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, 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)
}
@@ -244,7 +261,8 @@ func verifyProjectState(git common.Git, org string, config *common.AutogitConfig
remoteName, err := git.GitClone(gitPrj, gitBranch, repo.SSHURL)
common.PanicOnError(err)
defer git.GitExecQuietOrPanic(gitPrj, "submodule", "deinit", "--all")
git.GitExecQuietOrPanic(gitPrj, "submodule", "deinit", "--all", "-f")
defer git.GitExecQuietOrPanic(gitPrj, "submodule", "deinit", "--all", "-f")
common.LogDebug(" * Getting submodule list")
sub, err := git.GitSubmoduleList(gitPrj, "HEAD")
@@ -279,15 +297,18 @@ next_package:
//}
branch := config.Branch
common.LogDebug(" verifying package: %s -> %s(%s)", commitId, filename, 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
}
if len(branch) == 0 {
} 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)
@@ -399,7 +420,7 @@ next_repo:
}
// 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))
curBranch := strings.TrimSpace(git.GitExecWithOutputOrPanic(path.Join(gitPrj, r.Name), "branch", "--show-current"))
if branch != curBranch {
@@ -413,7 +434,7 @@ next_repo:
}
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)
}
@@ -501,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,6 +541,29 @@ func main() {
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{}
var err error

View File

@@ -1,54 +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.
* _Permissions_: permissions and associated accounts/groups. See below.
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* | 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
---------
@@ -57,39 +68,72 @@ 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
-----------
@@ -109,3 +153,11 @@ the `workflow.config`.
NOTE: Project Maintainers have these permissions automatically.
Server configuration
--------------------------
**Configuration file:**
| Field | Type | Notes |
| ----- | ----- | ----- |
| root | Array of string | Format **org/repo\#branch** |

View File

@@ -1,18 +0,0 @@
package interfaces
import "src.opensuse.org/autogits/common"
//go:generate mockgen -source=state_checker.go -destination=../mock/state_checker.go -typed -package mock_main
type StateChecker interface {
VerifyProjectState(configs *common.AutogitConfig) ([]*PRToProcess, error)
CheckRepos() error
ConsistencyCheckProcess() error
}
type PRToProcess struct {
Org, Repo, Branch string
}

View File

@@ -44,19 +44,35 @@ var CurrentUser *models.User
var GitHandler common.GitHandlerGenerator
var Gitea common.Gitea
func getEnvOverrideString(env, def string) string {
if envValue := os.Getenv(env); len(envValue) != 0 {
return envValue
}
return def
}
func getEnvOverrideBool(env string, def bool) bool {
if envValue := os.Getenv(env); len(envValue) != 0 {
if value, err := strconv.Atoi(envValue); err == nil && value > 0 {
return true
}
}
return def
}
func main() {
flag.StringVar(&GitAuthor, "git-author", "AutoGits PR Review Bot", "Git commit author")
flag.StringVar(&GitEmail, "git-email", "amajer+devel-git@suse.de", "Git commit email")
workflowConfig := flag.String("config", "", "Repository and workflow definition file")
workflowConfig := flag.String("config", getEnvOverrideString("AUTOGITS_CONFIG", ""), "Repository and workflow definition file")
giteaUrl := flag.String("gitea-url", "https://src.opensuse.org", "Gitea instance")
rabbitUrl := flag.String("url", "amqps://rabbit.opensuse.org", "URL for RabbitMQ instance")
debugMode := flag.Bool("debug", false, "Extra debugging information")
checkOnStart := flag.Bool("check-on-start", false, "Check all repositories for consistency on start, without delays")
debugMode := flag.Bool("debug", getEnvOverrideBool("AUTOGITS_DEBUG", false), "Extra debugging information")
checkOnStart := flag.Bool("check-on-start", getEnvOverrideBool("AUTOGITS_CHECK_ON_START", false), "Check all repositories for consistency on start, without delays")
checkIntervalHours := flag.Float64("check-interval", 5, "Check interval (+-random delay) for repositories for consitency, in hours")
flag.BoolVar(&ListPROnly, "list-prs-only", false, "Only lists PRs without acting on them")
flag.Int64Var(&PRID, "id", -1, "Process only the specific ID and ignore the rest. Use for debugging")
basePath := flag.String("repo-path", "", "Repository path. Default is temporary directory")
basePath := flag.String("repo-path", getEnvOverrideString("AUTOGITS_REPO_PATH", ""), "Repository path. Default is temporary directory")
pr := flag.String("only-pr", "", "Only specific PR to process. For debugging")
flag.BoolVar(&common.IsDryRun, "dry", false, "Dry mode. Do not push changes to remote repo.")
flag.Parse()
@@ -170,7 +186,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

@@ -2,10 +2,8 @@ package main
import (
"bytes"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -22,83 +20,6 @@ func TestProjectBranchName(t *testing.T) {
}
}
const LocalCMD = "---"
func gitExecs(t *testing.T, git *common.GitHandlerImpl, cmds [][]string) {
for _, cmd := range cmds {
if cmd[0] == LocalCMD {
command := exec.Command(cmd[2], cmd[3:]...)
command.Dir = filepath.Join(git.GitPath, cmd[1])
command.Stdin = nil
command.Env = append([]string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_1=protocol.file.allow", "GIT_CONFIG_VALUE_1=always"}, common.ExtraGitParams...)
_, err := command.CombinedOutput()
if err != nil {
t.Errorf(" *** error: %v\n", err)
}
} else {
git.GitExecOrPanic(cmd[0], cmd[1:]...)
}
}
}
func commandsForPackages(dir, prefix string, startN, endN int) [][]string {
commands := make([][]string, (endN-startN+2)*6)
if dir == "" {
dir = "."
}
cmdIdx := 0
for idx := startN; idx <= endN; idx++ {
pkgDir := fmt.Sprintf("%s%d", prefix, idx)
commands[cmdIdx+0] = []string{"", "init", "-q", "--object-format", "sha256", "-b", "testing", pkgDir}
commands[cmdIdx+1] = []string{LocalCMD, pkgDir, "/usr/bin/touch", "testFile"}
commands[cmdIdx+2] = []string{pkgDir, "add", "testFile"}
commands[cmdIdx+3] = []string{pkgDir, "commit", "-m", "added testFile"}
commands[cmdIdx+4] = []string{pkgDir, "config", "receive.denyCurrentBranch", "ignore"}
commands[cmdIdx+5] = []string{"prj", "submodule", "add", filepath.Join("..", pkgDir), filepath.Join(dir, pkgDir)}
cmdIdx += 6
}
// add all the submodules to the prj
commands[cmdIdx+0] = []string{"prj", "commit", "-a", "-m", "adding subpackages"}
return commands
}
func setupGitForTests(t *testing.T, git *common.GitHandlerImpl) {
common.ExtraGitParams = []string{
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=protocol.file.allow",
"GIT_CONFIG_VALUE_0=always",
"GIT_AUTHOR_NAME=testname",
"GIT_AUTHOR_EMAIL=test@suse.com",
"GIT_AUTHOR_DATE='2005-04-07T22:13:13'",
"GIT_COMMITTER_NAME=testname",
"GIT_COMMITTER_EMAIL=test@suse.com",
"GIT_COMMITTER_DATE='2005-04-07T22:13:13'",
}
gitExecs(t, git, [][]string{
{"", "init", "-q", "--object-format", "sha256", "-b", "testing", "prj"},
{"", "init", "-q", "--object-format", "sha256", "-b", "testing", "foo"},
{LocalCMD, "foo", "/usr/bin/touch", "file1"},
{"foo", "add", "file1"},
{"foo", "commit", "-m", "first commit"},
{"prj", "config", "receive.denyCurrentBranch", "ignore"},
{"prj", "submodule", "init"},
{"prj", "submodule", "add", "../foo", "testRepo"},
{"prj", "add", ".gitmodules", "testRepo"},
{"prj", "commit", "-m", "First instance"},
{"prj", "submodule", "deinit", "testRepo"},
{LocalCMD, "foo", "/usr/bin/touch", "file2"},
{"foo", "add", "file2"},
{"foo", "commit", "-m", "added file2"},
})
}
func TestUpdatePrBranch(t *testing.T) {
var buf bytes.Buffer
origLogger := log.Writer()
@@ -125,7 +46,7 @@ func TestUpdatePrBranch(t *testing.T) {
req.Pull_Request.Base.Sha = strings.TrimSpace(revs[1])
req.Pull_Request.Head.Sha = strings.TrimSpace(revs[0])
updateSubmoduleInPR("mainRepo", revs[0], git)
updateSubmoduleInPR("testRepo", revs[0], git)
common.PanicOnError(git.GitExec(common.DefaultGitPrj, "commit", "-a", "-m", "created commit"))
common.PanicOnError(git.GitExec(common.DefaultGitPrj, "push", "origin", "+HEAD:+testing"))
git.GitExecOrPanic("prj", "reset", "--hard", "testing")

View File

@@ -1,10 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: pr_processor.go
//
// Generated by this command:
//
// mockgen -source=pr_processor.go -destination=mock/pr_processor.go -typed
//
// Package mock_main is a generated GoMock package.
package mock_main

View File

@@ -3,18 +3,18 @@
//
// Generated by this command:
//
// mockgen -source=state_checker.go -destination=../mock/state_checker.go -typed -package mock_main
// mockgen -source=state_checker.go -destination=mock_state_checker.go -typed -package main
//
// Package mock_main is a generated GoMock package.
package mock_main
// Package main is a generated GoMock package.
package main
import (
reflect "reflect"
gomock "go.uber.org/mock/gomock"
common "src.opensuse.org/autogits/common"
interfaces "src.opensuse.org/autogits/workflow-pr/interfaces"
models "src.opensuse.org/autogits/common/gitea-generated/models"
)
// MockStateChecker is a mock of StateChecker interface.
@@ -42,11 +42,9 @@ func (m *MockStateChecker) EXPECT() *MockStateCheckerMockRecorder {
}
// CheckRepos mocks base method.
func (m *MockStateChecker) CheckRepos() error {
func (m *MockStateChecker) CheckRepos() {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CheckRepos")
ret0, _ := ret[0].(error)
return ret0
m.ctrl.Call(m, "CheckRepos")
}
// CheckRepos indicates an expected call of CheckRepos.
@@ -62,19 +60,19 @@ type MockStateCheckerCheckReposCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockStateCheckerCheckReposCall) Return(arg0 error) *MockStateCheckerCheckReposCall {
c.Call = c.Call.Return(arg0)
func (c *MockStateCheckerCheckReposCall) Return() *MockStateCheckerCheckReposCall {
c.Call = c.Call.Return()
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockStateCheckerCheckReposCall) Do(f func() error) *MockStateCheckerCheckReposCall {
func (c *MockStateCheckerCheckReposCall) Do(f func()) *MockStateCheckerCheckReposCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockStateCheckerCheckReposCall) DoAndReturn(f func() error) *MockStateCheckerCheckReposCall {
func (c *MockStateCheckerCheckReposCall) DoAndReturn(f func()) *MockStateCheckerCheckReposCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
@@ -118,10 +116,10 @@ func (c *MockStateCheckerConsistencyCheckProcessCall) DoAndReturn(f func() error
}
// VerifyProjectState mocks base method.
func (m *MockStateChecker) VerifyProjectState(configs *common.AutogitConfig) ([]*interfaces.PRToProcess, error) {
func (m *MockStateChecker) VerifyProjectState(configs *common.AutogitConfig) ([]*PRToProcess, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "VerifyProjectState", configs)
ret0, _ := ret[0].([]*interfaces.PRToProcess)
ret0, _ := ret[0].([]*PRToProcess)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -139,19 +137,81 @@ type MockStateCheckerVerifyProjectStateCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockStateCheckerVerifyProjectStateCall) Return(arg0 []*interfaces.PRToProcess, arg1 error) *MockStateCheckerVerifyProjectStateCall {
func (c *MockStateCheckerVerifyProjectStateCall) Return(arg0 []*PRToProcess, arg1 error) *MockStateCheckerVerifyProjectStateCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockStateCheckerVerifyProjectStateCall) Do(f func(*common.AutogitConfig) ([]*interfaces.PRToProcess, error)) *MockStateCheckerVerifyProjectStateCall {
func (c *MockStateCheckerVerifyProjectStateCall) Do(f func(*common.AutogitConfig) ([]*PRToProcess, error)) *MockStateCheckerVerifyProjectStateCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockStateCheckerVerifyProjectStateCall) DoAndReturn(f func(*common.AutogitConfig) ([]*interfaces.PRToProcess, error)) *MockStateCheckerVerifyProjectStateCall {
func (c *MockStateCheckerVerifyProjectStateCall) DoAndReturn(f func(*common.AutogitConfig) ([]*PRToProcess, error)) *MockStateCheckerVerifyProjectStateCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockPullRequestProcessor is a mock of PullRequestProcessor interface.
type MockPullRequestProcessor struct {
ctrl *gomock.Controller
recorder *MockPullRequestProcessorMockRecorder
isgomock struct{}
}
// MockPullRequestProcessorMockRecorder is the mock recorder for MockPullRequestProcessor.
type MockPullRequestProcessorMockRecorder struct {
mock *MockPullRequestProcessor
}
// NewMockPullRequestProcessor creates a new mock instance.
func NewMockPullRequestProcessor(ctrl *gomock.Controller) *MockPullRequestProcessor {
mock := &MockPullRequestProcessor{ctrl: ctrl}
mock.recorder = &MockPullRequestProcessorMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPullRequestProcessor) EXPECT() *MockPullRequestProcessorMockRecorder {
return m.recorder
}
// Process mocks base method.
func (m *MockPullRequestProcessor) Process(req *models.PullRequest) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Process", req)
ret0, _ := ret[0].(error)
return ret0
}
// Process indicates an expected call of Process.
func (mr *MockPullRequestProcessorMockRecorder) Process(req any) *MockPullRequestProcessorProcessCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Process", reflect.TypeOf((*MockPullRequestProcessor)(nil).Process), req)
return &MockPullRequestProcessorProcessCall{Call: call}
}
// MockPullRequestProcessorProcessCall wrap *gomock.Call
type MockPullRequestProcessorProcessCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockPullRequestProcessorProcessCall) Return(arg0 error) *MockPullRequestProcessorProcessCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockPullRequestProcessorProcessCall) Do(f func(*models.PullRequest) error) *MockPullRequestProcessorProcessCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockPullRequestProcessorProcessCall) DoAndReturn(f func(*models.PullRequest) error) *MockPullRequestProcessorProcessCall {
c.Call = c.Call.DoAndReturn(f)
return c
}

View File

@@ -1,7 +1,5 @@
package main
//go:generate mockgen -source=pr_processor.go -destination=mock/pr_processor.go -typed
import (
"encoding/json"
"errors"
@@ -26,6 +24,7 @@ func PrjGitDescription(prset *common.PRSet) (title string, desc string) {
title_refs := make([]string, 0, len(prset.PRs)-1)
refs := make([]string, 0, len(prset.PRs)-1)
prefix := ""
for _, pr := range prset.PRs {
if prset.IsPrjGitPR(pr.PR) {
continue
@@ -34,6 +33,9 @@ func PrjGitDescription(prset *common.PRSet) (title string, desc string) {
// remove PRs that are not open from description
continue
}
if strings.HasPrefix(pr.PR.Title, "WIP:") {
prefix = "WIP: "
}
org, repo, idx := pr.PRComponents()
title_refs = append(title_refs, repo)
@@ -41,7 +43,10 @@ func PrjGitDescription(prset *common.PRSet) (title string, desc string) {
refs = append(refs, ref)
}
title = "Forwarded PRs: " + strings.Join(title_refs, ", ")
slices.Sort(title_refs)
slices.Sort(refs)
title = prefix + "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"
if prset.Config.ManualMergeOnly {
@@ -227,12 +232,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,
})
@@ -280,6 +291,9 @@ func (pr *PRProcessor) UpdatePrjGitPR(prset *common.PRSet) error {
git := pr.git
if len(prset.PRs) == 1 {
if len(PrjGitPR.RemoteName) == 0 {
PrjGitPR.RemoteName, _ = git.GitClone(common.DefaultGitPrj, "", PrjGitPR.PR.Base.Repo.SSHURL)
}
git.GitExecOrPanic(common.DefaultGitPrj, "fetch", PrjGitPR.RemoteName, PrjGitPR.PR.Head.Sha)
common.LogDebug("Only project git in PR. Nothing to update.")
return nil
@@ -346,7 +360,20 @@ func (pr *PRProcessor) UpdatePrjGitPR(prset *common.PRSet) error {
}
// 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,
@@ -382,6 +409,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 {
@@ -474,7 +505,7 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
// make sure that prjgit is consistent and only submodules that are to be *updated*
// reset anything that changed that is not part of the prset
// package removals/additions are *not* counted here
org, repo, branch := config.GetPrjGit()
// TODO: this is broken...
if pr, err := prset.GetPrjGitPR(); err == nil && false {
common.LogDebug("Submodule parse begin")
@@ -523,11 +554,19 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
} else {
common.LogInfo("* No prjgit")
}
maintainers, err := common.FetchProjectMaintainershipData(Gitea, org, repo, branch)
maintainers, err := common.FetchProjectMaintainershipData(Gitea, config)
if err != nil {
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")
@@ -566,7 +605,7 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
common.LogError("merge error:", err)
}
} else {
prset.AssignReviewers(Gitea, maintainers)
err = prset.AssignReviewers(Gitea, maintainers)
}
return err
}
@@ -592,6 +631,15 @@ func ProcesPullRequest(pr *models.PullRequest, configs []*common.AutogitConfig)
return PRProcessor.Process(pr)
}
func (w *RequestProcessor) Process(pr *models.PullRequest) error {
configs, ok := w.configuredRepos[pr.Base.Repo.Owner.UserName]
if !ok {
common.LogError("*** Cannot find config for org:", pr.Base.Repo.Owner.UserName)
return fmt.Errorf("*** Cannot find config for org: %s", pr.Base.Repo.Owner.UserName)
}
return ProcesPullRequest(pr, configs)
}
func (w *RequestProcessor) ProcessFunc(request *common.Request) (err error) {
defer func() {
if r := recover(); r != nil {

View File

@@ -6,7 +6,6 @@ import (
"go.uber.org/mock/gomock"
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/client/repository"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
)
@@ -17,7 +16,7 @@ func TestOpenPR(t *testing.T) {
Reviewers: []string{"reviewer1", "reviewer2"},
Branch: "branch",
Organization: "test",
GitProjectName: "prj",
GitProjectName: "prj#testing",
},
}
@@ -26,6 +25,7 @@ func TestOpenPR(t *testing.T) {
Number: 1,
Pull_Request: &common.PullRequest{
Id: 1,
Number: 1,
Base: common.Head{
Ref: "branch",
Sha: "testing",
@@ -53,6 +53,56 @@ func TestOpenPR(t *testing.T) {
},
}
modelPR := &models.PullRequest{
ID: 1,
Index: 1,
State: "open",
User: &models.User{UserName: "testuser"},
RequestedReviewers: []*models.User{},
Base: &models.PRBranchInfo{
Ref: "branch",
Sha: "testing",
Repo: &models.Repository{
Name: "testRepo",
Owner: &models.User{
UserName: "test",
},
},
},
Head: &models.PRBranchInfo{
Ref: "branch",
Sha: "testing",
Repo: &models.Repository{
Name: "testRepo",
Owner: &models.User{
UserName: "test",
},
},
},
}
mockCreatePR := &models.PullRequest{
ID: 2,
Index: 2,
Body: "Forwarded PRs: testRepo\n\nPR: test/testRepo!1",
User: &models.User{UserName: "testuser"},
RequestedReviewers: []*models.User{},
Base: &models.PRBranchInfo{
Name: "testing",
Repo: &models.Repository{
Name: "prjcopy",
Owner: &models.User{UserName: "test"},
},
},
Head: &models.PRBranchInfo{
Sha: "head",
},
}
CurrentUser = &models.User{
UserName: "testuser",
}
git := &common.GitHandlerImpl{
GitCommiter: "tester",
GitEmail: "test@suse.com",
@@ -60,14 +110,47 @@ func TestOpenPR(t *testing.T) {
t.Run("PR git opened request against PrjGit == no action", func(t *testing.T) {
ctl := gomock.NewController(t)
Gitea = mock_common.NewMockGitea(ctl)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
git.GitPath = t.TempDir()
pr.config.GitProjectName = "testRepo"
pr.config.GitProjectName = "testRepo#testing"
event.Repository.Name = "testRepo"
mockGit := mock_common.NewMockGit(ctl)
pr.git = mockGit
if err := pr.Process(event); err != nil {
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
mockGitGen.EXPECT().CreateGitHandler(gomock.Any()).Return(mockGit, nil).AnyTimes()
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().UpdatePullRequest(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().SetLabels(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.Label{}, nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "git@src.opensuse.org:test/prj.git"}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(&models.Repository{
Owner: &models.User{UserName: "test"},
Name: "prjcopy",
SSHURL: "git@src.opensuse.org:test/prj.git",
}, nil).AnyTimes()
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "git@src.opensuse.org:test/prj.git"}, nil).AnyTimes()
gitea.EXPECT().CreatePullRequestIfNotExist(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockCreatePR, nil, true).AnyTimes()
gitea.EXPECT().RequestReviews(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
if err := pr.Process(modelPR); err != nil {
t.Error("Error PrjGit opened request. Should be no error.", err)
}
})
@@ -75,43 +158,52 @@ func TestOpenPR(t *testing.T) {
t.Run("Open PrjGit PR", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
event.Repository.Name = "testRepo"
pr.config.GitProjectName = "prjcopy"
pr.config.GitProjectName = "prjcopy#testing"
git.GitPath = t.TempDir()
setupGitForTests(t, git)
prjgit := &models.Repository{
SSHURL: "./prj",
DefaultBranch: "testing",
}
giteaPR := &models.PullRequest{
Base: &models.PRBranchInfo{
Repo: &models.Repository{
Owner: &models.User{
UserName: "test",
},
Name: "testRepo",
},
},
User: &models.User{
UserName: "test",
},
}
// 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().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)
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "git@src.opensuse.org:test/prj.git"}, nil).AnyTimes()
gitea.EXPECT().CreatePullRequestIfNotExist(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockCreatePR, nil, true).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().RequestReviews(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile("test", "prjcopy", "branch", "_project").Return(nil, "", repository.NewRepoGetRawFileNotFound())
gitea.EXPECT().FetchMaintainershipFile("test", "prjcopy", "branch").Return(nil, "", repository.NewRepoGetRawFileNotFound())
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
err := pr.Process(event)
mockGit := mock_common.NewMockGit(ctl)
pr.git = mockGit
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
mockGitGen.EXPECT().CreateGitHandler(gomock.Any()).Return(mockGit, nil).AnyTimes()
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().UpdatePullRequest(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().SetLabels(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.Label{}, nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "git@src.opensuse.org:test/prj.git"}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(&models.Repository{
Owner: &models.User{UserName: "test"},
Name: "prjcopy",
SSHURL: "git@src.opensuse.org:test/prj.git",
}, nil).AnyTimes()
err := pr.Process(modelPR)
if err != nil {
t.Error("error:", err)
}
@@ -120,30 +212,61 @@ func TestOpenPR(t *testing.T) {
t.Run("Cannot create prjgit repository", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
event.Repository.Name = "testRepo"
pr.config.GitProjectName = "prjcopy"
pr.config.GitProjectName = "prjcopy#testing"
git.GitPath = t.TempDir()
setupGitForTests(t, git)
failedErr := errors.New("Returned error here")
gitea.EXPECT().CreateRepositoryIfNotExist(git, "test", "prjcopy").Return(nil, failedErr)
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, failedErr).AnyTimes()
gitea.EXPECT().CreatePullRequestIfNotExist(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockCreatePR, nil, true).AnyTimes()
err := pr.Process(event)
if err != failedErr {
mockGit := mock_common.NewMockGit(ctl)
pr.git = mockGit
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
mockGitGen.EXPECT().CreateGitHandler(gomock.Any()).Return(mockGit, nil).AnyTimes()
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
gitea.EXPECT().RequestReviews(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().UpdatePullRequest(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().SetLabels(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.Label{}, nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "git@src.opensuse.org:test/prj.git"}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(&models.Repository{
Owner: &models.User{UserName: "test"},
Name: "prjcopy",
SSHURL: "git@src.opensuse.org:test/prj.git",
}, nil).AnyTimes()
err := pr.Process(modelPR)
if err != nil {
t.Error("error:", err)
}
})
t.Run("Cannot create PR", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
event.Repository.Name = "testRepo"
pr.config.GitProjectName = "prjcopy"
pr.config.GitProjectName = "prjcopy#testing"
git.GitPath = t.TempDir()
setupGitForTests(t, git)
@@ -152,10 +275,37 @@ func TestOpenPR(t *testing.T) {
DefaultBranch: "testing",
}
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().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), gomock.Any()).Return(prjgit, nil).AnyTimes()
gitea.EXPECT().CreatePullRequestIfNotExist(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, failedErr, false)
err := pr.Process(event)
mockGit := mock_common.NewMockGit(ctl)
pr.git = mockGit
gitea.EXPECT().RequestReviews(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
mockGitGen.EXPECT().CreateGitHandler(gomock.Any()).Return(mockGit, nil).AnyTimes()
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().UpdatePullRequest(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().SetLabels(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.Label{}, nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "git@src.opensuse.org:test/prj.git"}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(&models.Repository{
Owner: &models.User{UserName: "test"},
Name: "prjcopy",
SSHURL: "git@src.opensuse.org:test/prj.git",
}, nil).AnyTimes()
err := pr.Process(modelPR)
if err != failedErr {
t.Error("error:", err)
}
@@ -163,44 +313,54 @@ func TestOpenPR(t *testing.T) {
t.Run("Open PrjGit PR", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
event.Repository.Name = "testRepo"
pr.config.GitProjectName = "prjcopy"
pr.config.GitProjectName = "prjcopy#testing"
git.GitPath = t.TempDir()
setupGitForTests(t, git)
prjgit := &models.Repository{
Name: "SomeRepo",
Owner: &models.User{
UserName: "org",
},
SSHURL: "./prj",
DefaultBranch: "testing",
}
giteaPR := &models.PullRequest{
Base: &models.PRBranchInfo{
Repo: prjgit,
},
Index: 13,
User: &models.User{
UserName: "test",
},
}
failedErr := errors.New("Returned error here")
// gitea.EXPECT().GetAssociatedPrjGitPR("test", "prjcopy", "test", "testRepo", int64(1)).Return(nil, nil)
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().RequestReviews(giteaPR, "reviewer1", "reviewer2").Return(nil, failedErr)
gitea.EXPECT().FetchMaintainershipDirFile("test", "prjcopy", "branch", "_project").Return(nil, "", repository.NewRepoGetRawFileNotFound())
gitea.EXPECT().FetchMaintainershipFile("test", "prjcopy", "branch").Return(nil, "", repository.NewRepoGetRawFileNotFound())
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "git@src.opensuse.org:test/prj.git"}, nil).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().CreatePullRequestIfNotExist(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockCreatePR, nil, true).AnyTimes()
gitea.EXPECT().RequestReviews(gomock.Any(), gomock.Any()).Return(nil, failedErr).AnyTimes()
err := pr.Process(event)
if errors.Unwrap(err) != failedErr {
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
mockGit := mock_common.NewMockGit(ctl)
pr.git = mockGit
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
mockGitGen.EXPECT().CreateGitHandler(gomock.Any()).Return(mockGit, nil).AnyTimes()
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"testRepo": "testing"}, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().UpdatePullRequest(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelPR, nil).AnyTimes()
gitea.EXPECT().SetLabels(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.Label{}, nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "git@src.opensuse.org:test/prj.git"}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(&models.Repository{
Owner: &models.User{UserName: "test"},
Name: "prjcopy",
SSHURL: "git@src.opensuse.org:test/prj.git",
}, nil).AnyTimes()
err := pr.Process(modelPR)
if err != nil {
t.Error("error:", err)
}
})

View File

@@ -1,12 +1,7 @@
package main
/*
import (
"bytes"
"errors"
"log"
"os"
"path"
"strings"
"testing"
"go.uber.org/mock/gomock"
@@ -16,217 +11,147 @@ import (
)
func TestSyncPR(t *testing.T) {
pr := PRProcessor{
config: &common.AutogitConfig{
Reviewers: []string{"reviewer1", "reviewer2"},
Branch: "testing",
Organization: "test",
GitProjectName: "prj",
},
config := &common.AutogitConfig{
Reviewers: []string{"reviewer1", "reviewer2"},
Branch: "testing",
Organization: "test-org",
GitProjectName: "test-prj#testing",
}
event := &common.PullRequestWebhookEvent{
Action: "syncronized",
Number: 42,
Pull_Request: &common.PullRequest{
Number: 42,
Base: common.Head{
Ref: "branch",
Sha: "8a6a69a4232cabda04a4d9563030aa888ff5482f75aa4c6519da32a951a072e2",
Repo: &common.Repository{
Name: "testRepo",
Owner: &common.Organization{
Username: pr.config.Organization,
},
Default_Branch: "main1",
},
},
Head: common.Head{
Ref: "branch",
Sha: "11eb36d5a58d7bb376cac59ac729a1986c6a7bfc63e7818e14382f545ccda985",
Repo: &common.Repository{
Name: "testRepo",
Default_Branch: "main1",
},
},
},
Repository: &common.Repository{
Owner: &common.Organization{
Username: pr.config.Organization,
},
},
git := &common.GitHandlerImpl{
GitCommiter: "tester",
GitEmail: "test@suse.com",
GitPath: t.TempDir(),
}
processor := &PRProcessor{
config: config,
git: git,
}
modelPR := &models.PullRequest{
Index: 42,
Body: "PR: test/prj#24",
Body: "PR: test-org/test-prj#24",
Base: &models.PRBranchInfo{
Ref: "branch",
Sha: "8a6a69a4232cabda04a4d9563030aa888ff5482f75aa4c6519da32a951a072e2",
Ref: "main",
Repo: &models.Repository{
Name: "testRepo",
Owner: &models.User{
UserName: "test",
},
DefaultBranch: "main1",
Name: "test-repo",
Owner: &models.User{UserName: "test-org"},
DefaultBranch: "main",
},
},
Head: &models.PRBranchInfo{
Ref: "branch",
Sha: "11eb36d5a58d7bb376cac59ac729a1986c6a7bfc63e7818e14382f545ccda985",
Repo: &models.Repository{
Name: "testRepo",
Owner: &models.User{
UserName: "test",
},
DefaultBranch: "main1",
Name: "test-repo",
Owner: &models.User{UserName: "test-org"},
DefaultBranch: "main",
},
},
}
PrjGitPR := &models.PullRequest{
Title: "some pull request",
Body: "PR: test/testRepo#42",
Body: "PR: test-org/test-repo#42",
Index: 24,
Base: &models.PRBranchInfo{
Ref: "testing",
Repo: &models.Repository{
Name: "test-prj",
Owner: &models.User{UserName: "test-org"},
SSHURL: "url",
},
},
Head: &models.PRBranchInfo{
Name: "testing",
Name: "PR_test-repo#42",
Sha: "db8adab91edb476b9762097d10c6379aa71efd6b60933a1c0e355ddacf419a95",
Repo: &models.Repository{
SSHURL: "./prj",
SSHURL: "url",
},
},
}
git := &common.GitHandlerImpl{
GitCommiter: "tester",
GitEmail: "test@suse.com",
}
t.Run("PR sync request against PrjGit == no action", func(t *testing.T) {
t.Run("PR_sync_request_against_PrjGit_==_no_action", func(t *testing.T) {
ctl := gomock.NewController(t)
Gitea = mock_common.NewMockGitea(ctl)
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
git.GitPath = t.TempDir()
// Common expectations for FetchPRSet and downstream checks
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
pr.config.GitProjectName = "testRepo"
event.Repository.Name = "testRepo"
if err := pr.Process(event); err != nil {
t.Error("Error PrjGit sync request. Should be no error.", err)
prjGitRepoPR := &models.PullRequest{
Index: 100,
Base: &models.PRBranchInfo{
Ref: "testing",
Repo: &models.Repository{
Name: "test-prj",
Owner: &models.User{UserName: "test-org"},
},
},
Head: &models.PRBranchInfo{
Ref: "branch",
},
}
})
t.Run("Missing submodule in prjgit", func(t *testing.T) {
ctl := gomock.NewController(t)
mock := mock_common.NewMockGitea(ctl)
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(prjGitRepoPR, nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
pr.gitea = mock
git.GitPath = t.TempDir()
pr.config.GitProjectName = "prjGit"
event.Repository.Name = "testRepo"
setupGitForTests(t, git)
oldSha := PrjGitPR.Head.Sha
defer func() { PrjGitPR.Head.Sha = oldSha }()
PrjGitPR.Head.Sha = "ab8adab91edb476b9762097d10c6379aa71efd6b60933a1c0e355ddacf419a95"
mock.EXPECT().GetPullRequest(pr.config.Organization, "testRepo", event.Pull_Request.Number).Return(modelPR, nil)
mock.EXPECT().GetPullRequest(pr.config.Organization, "prj", int64(24)).Return(PrjGitPR, nil)
err := pr.Process(event)
if err == nil || err.Error() != "Cannot fetch submodule commit id in prjgit for 'testRepo'" {
t.Error("Invalid error received.", err)
if err := processor.Process(prjGitRepoPR); err != nil {
t.Errorf("Expected nil error for PrjGit sync request, got %v", err)
}
})
t.Run("Missing PrjGit PR for the sync", func(t *testing.T) {
ctl := gomock.NewController(t)
mock := mock_common.NewMockGitea(ctl)
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
pr.gitea = mock
git.GitPath = t.TempDir()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
pr.config.GitProjectName = "prjGit"
event.Repository.Name = "tester"
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("not found")).AnyTimes()
setupGitForTests(t, git)
expectedErr := errors.New("Missing PR should throw error")
mock.EXPECT().GetPullRequest(config.Organization, "tester", event.Pull_Request.Number).Return(modelPR, expectedErr)
err := pr.Process(event, git, config)
if err == nil || errors.Unwrap(err) != expectedErr {
t.Error("Invalid error received.", err)
err := processor.Process(modelPR)
// It should fail because it can't find the project PR linked in body
if err == nil {
t.Errorf("Expected error for missing project PR, got nil")
}
})
t.Run("PR sync", func(t *testing.T) {
var b bytes.Buffer
w := log.Writer()
log.SetOutput(&b)
defer log.SetOutput(w)
ctl := gomock.NewController(t)
mock := mock_common.NewMockGitea(ctl)
Gitea = mock
git.GitPath = t.TempDir()
pr.config.GitProjectName = "prjGit"
event.Repository.Name = "testRepo"
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
setupGitForTests(t, git)
// mock.EXPECT().GetAssociatedPrjGitPR(event).Return(PrjGitPR, nil)
mock.EXPECT().GetPullRequest(pr.config.Organization, "testRepo", event.Pull_Request.Number).Return(modelPR, nil)
mock.EXPECT().GetPullRequest(pr.config.Organization, "prj", int64(24)).Return(PrjGitPR, nil)
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(PrjGitPR, nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
err := pr.Process(event)
// For UpdatePrjGitPR
gitea.EXPECT().UpdatePullRequest(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
err := processor.Process(modelPR)
if err != nil {
t.Error("Invalid error received.", err)
t.Error(b.String())
}
// check that we actually created the branch in the prjgit
id, ok := git.GitSubmoduleCommitId("prj", "testRepo", "c097b9d1d69892d0ef2afa66d4e8abf0a1612c6f95d271a6e15d6aff1ad2854c")
if id != "11eb36d5a58d7bb376cac59ac729a1986c6a7bfc63e7818e14382f545ccda985" || !ok {
t.Error("Failed creating PR")
t.Error(b.String())
}
// does nothing on next sync of already synced data -- PR is updated
os.RemoveAll(path.Join(git.GitPath, common.DefaultGitPrj))
mock.EXPECT().GetPullRequest(pr.config.Organization, "testRepo", event.Pull_Request.Number).Return(modelPR, nil)
mock.EXPECT().GetPullRequest(pr.config.Organization, "prj", int64(24)).Return(PrjGitPR, nil)
err = pr.Process(event)
if err != nil {
t.Error("Invalid error received.", err)
t.Error(b.String())
}
// check that we actually created the branch in the prjgit
id, ok = git.GitSubmoduleCommitId("prj", "testRepo", "c097b9d1d69892d0ef2afa66d4e8abf0a1612c6f95d271a6e15d6aff1ad2854c")
if id != "11eb36d5a58d7bb376cac59ac729a1986c6a7bfc63e7818e14382f545ccda985" || !ok {
t.Error("Failed creating PR")
t.Error(b.String())
}
if id, err := git.GitBranchHead("prj", "PR_testRepo#42"); id != "c097b9d1d69892d0ef2afa66d4e8abf0a1612c6f95d271a6e15d6aff1ad2854c" || err != nil {
t.Error("no branch?", err)
t.Error(b.String())
}
if !strings.Contains(b.String(), "commitID already match - nothing to do") {
// os.CopyFS("/tmp/test", os.DirFS(git.GitPath))
t.Log(b.String())
t.Errorf("Unexpected error: %v", err)
}
})
}
*/

View File

@@ -0,0 +1,945 @@
package main
import (
"errors"
"fmt"
"strings"
"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 TestPrjGitDescription(t *testing.T) {
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
prset := &common.PRSet{
Config: config,
PRs: []*common.PRInfo{
{
PR: &models.PullRequest{
State: "open",
Index: 1,
Base: &models.PRBranchInfo{
Ref: "main",
Repo: &models.Repository{
Name: "pkg-a",
Owner: &models.User{UserName: "test-org"},
},
},
},
},
{
PR: &models.PullRequest{
State: "open",
Index: 2,
Base: &models.PRBranchInfo{
Ref: "main",
Repo: &models.Repository{
Name: "pkg-b",
Owner: &models.User{UserName: "test-org"},
},
},
},
},
},
}
GitAuthor = "Bot"
title, desc := PrjGitDescription(prset)
expectedTitle := "Forwarded PRs: pkg-a, pkg-b"
if title != expectedTitle {
t.Errorf("Expected title %q, got %q", expectedTitle, title)
}
if !strings.Contains(desc, "PR: test-org/pkg-a!1") || !strings.Contains(desc, "PR: test-org/pkg-b!2") {
t.Errorf("Description missing PR references: %s", desc)
}
}
func TestAllocatePRProcessor(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
mockGit := mock_common.NewMockGit(ctl)
configs := common.AutogitConfigs{
{
Organization: "test-org",
Branch: "main",
GitProjectName: "test-prj#main",
},
}
req := &models.PullRequest{
Index: 1,
Base: &models.PRBranchInfo{
Ref: "main",
Repo: &models.Repository{
Name: "test-repo",
Owner: &models.User{UserName: "test-org"},
},
},
}
mockGitGen.EXPECT().CreateGitHandler("test-org").Return(mockGit, nil)
mockGit.EXPECT().GetPath().Return("/tmp/test")
processor, err := AllocatePRProcessor(req, configs)
if err != nil {
t.Fatalf("AllocatePRProcessor failed: %v", err)
}
if processor.config.Organization != "test-org" {
t.Errorf("Expected organization test-org, got %s", processor.config.Organization)
}
}
func TestAllocatePRProcessor_Failures(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
configs := common.AutogitConfigs{} // Empty configs
req := &models.PullRequest{
Index: 1,
Base: &models.PRBranchInfo{
Ref: "main",
Repo: &models.Repository{
Name: "test-repo",
Owner: &models.User{UserName: "test-org"},
},
},
}
t.Run("Missing config", func(t *testing.T) {
processor, err := AllocatePRProcessor(req, configs)
if err == nil || err.Error() != "Cannot find config for PR" {
t.Errorf("Expected 'Cannot find config for PR' error, got %v", err)
}
if processor != nil {
t.Error("Expected nil processor")
}
})
t.Run("GitHandler failure", func(t *testing.T) {
validConfigs := common.AutogitConfigs{
{
Organization: "test-org",
Branch: "main",
GitProjectName: "test-prj#main",
},
}
mockGitGen.EXPECT().CreateGitHandler("test-org").Return(nil, errors.New("git error"))
processor, err := AllocatePRProcessor(req, validConfigs)
if err == nil || !strings.Contains(err.Error(), "Error allocating GitHandler") {
t.Errorf("Expected GitHandler error, got %v", err)
}
if processor != nil {
t.Error("Expected nil processor")
}
})
}
func TestSetSubmodulesToMatchPRSet_Failures(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
mockGit := mock_common.NewMockGit(ctl)
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
processor := &PRProcessor{
config: config,
git: mockGit,
}
t.Run("GitSubmoduleList failure", func(t *testing.T) {
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), "HEAD").Return(nil, errors.New("list error"))
err := processor.SetSubmodulesToMatchPRSet(&common.PRSet{})
if err == nil || err.Error() != "list error" {
t.Errorf("Expected 'list error', got %v", err)
}
})
}
func TestSetSubmodulesToMatchPRSet(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
mockGit := mock_common.NewMockGit(ctl)
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
processor := &PRProcessor{
config: config,
git: mockGit,
}
prset := &common.PRSet{
Config: config,
PRs: []*common.PRInfo{
{
PR: &models.PullRequest{
State: "open",
Index: 1,
Base: &models.PRBranchInfo{
Ref: "main",
Repo: &models.Repository{
Name: "pkg-a",
Owner: &models.User{UserName: "test-org"},
},
},
Head: &models.PRBranchInfo{
Sha: "new-sha",
},
},
},
},
}
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), "HEAD").Return(map[string]string{"pkg-a": "old-sha"}, nil)
// Expect submodule update and commit
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitStatus(gomock.Any()).Return([]common.GitStatusData{}, nil).AnyTimes()
err := processor.SetSubmodulesToMatchPRSet(prset)
if err != nil {
t.Errorf("SetSubmodulesToMatchPRSet failed: %v", err)
}
}
func TestRebaseAndSkipSubmoduleCommits(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
mockGit := mock_common.NewMockGit(ctl)
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
processor := &PRProcessor{
config: config,
git: mockGit,
}
prset := &common.PRSet{
Config: config,
PRs: []*common.PRInfo{
{
RemoteName: "origin",
PR: &models.PullRequest{
Base: &models.PRBranchInfo{
Name: "main",
Repo: &models.Repository{
Name: "test-prj",
Owner: &models.User{UserName: "test-org"},
},
},
},
},
},
}
t.Run("Clean rebase", func(t *testing.T) {
mockGit.EXPECT().GitExec(common.DefaultGitPrj, "rebase", "origin/main").Return(nil)
err := processor.RebaseAndSkipSubmoduleCommits(prset, "main")
if err != nil {
t.Errorf("Expected nil error, got %v", err)
}
})
t.Run("Rebase with submodule conflict - skip", func(t *testing.T) {
// First rebase fails
mockGit.EXPECT().GitExec(common.DefaultGitPrj, "rebase", "origin/main").Return(errors.New("conflict"))
// Status shows submodule change
mockGit.EXPECT().GitStatus(common.DefaultGitPrj).Return([]common.GitStatusData{
{SubmoduleChanges: "S..."},
}, nil)
// Skip called
mockGit.EXPECT().GitExec(common.DefaultGitPrj, "rebase", "--skip").Return(nil)
err := processor.RebaseAndSkipSubmoduleCommits(prset, "main")
if err != nil {
t.Errorf("Expected nil error, got %v", err)
}
})
t.Run("Rebase with real conflict - abort", func(t *testing.T) {
mockGit.EXPECT().GitExec(common.DefaultGitPrj, "rebase", "origin/main").Return(errors.New("conflict"))
// Status shows real change
mockGit.EXPECT().GitStatus(common.DefaultGitPrj).Return([]common.GitStatusData{
{SubmoduleChanges: "N..."},
}, nil)
// Abort called
mockGit.EXPECT().GitExecOrPanic(common.DefaultGitPrj, "rebase", "--abort").Return()
err := processor.RebaseAndSkipSubmoduleCommits(prset, "main")
if err == nil || !strings.Contains(err.Error(), "Unexpected conflict in rebase") {
t.Errorf("Expected 'Unexpected conflict' error, got %v", err)
}
})
}
func TestUpdatePrjGitPR(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
mockGit := mock_common.NewMockGit(ctl)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
CurrentUser = &models.User{UserName: "bot"}
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
processor := &PRProcessor{
config: config,
git: mockGit,
}
t.Run("Only project git in PR", func(t *testing.T) {
prset := &common.PRSet{
Config: config,
PRs: []*common.PRInfo{
{
RemoteName: "origin",
PR: &models.PullRequest{
Base: &models.PRBranchInfo{
Name: "main",
Repo: &models.Repository{
Name: "test-prj",
Owner: &models.User{UserName: "test-org"},
},
},
Head: &models.PRBranchInfo{
Sha: "sha1",
},
},
},
},
}
mockGit.EXPECT().GitExecOrPanic(common.DefaultGitPrj, "fetch", "origin", "sha1")
err := processor.UpdatePrjGitPR(prset)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
t.Run("Only project git in PR - needs clone", func(t *testing.T) {
prset := &common.PRSet{
Config: config,
PRs: []*common.PRInfo{
{
RemoteName: "", // Triggers GitClone
PR: &models.PullRequest{
Base: &models.PRBranchInfo{
Name: "main",
Repo: &models.Repository{
Name: "test-prj",
Owner: &models.User{UserName: "test-org"},
SSHURL: "ssh://git@example.com/test-prj.git",
},
},
Head: &models.PRBranchInfo{
Sha: "sha1",
},
},
},
},
}
mockGit.EXPECT().GitClone(common.DefaultGitPrj, "", "ssh://git@example.com/test-prj.git").Return("origin", nil)
mockGit.EXPECT().GitExecOrPanic(common.DefaultGitPrj, "fetch", "origin", "sha1")
err := processor.UpdatePrjGitPR(prset)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
t.Run("PR on another remote", func(t *testing.T) {
prset := &common.PRSet{
Config: config,
PRs: []*common.PRInfo{
{
RemoteName: "origin",
PR: &models.PullRequest{
Base: &models.PRBranchInfo{
Name: "main",
RepoID: 1,
Repo: &models.Repository{
Name: "test-prj",
Owner: &models.User{UserName: "test-org"},
SSHURL: "url",
},
},
Head: &models.PRBranchInfo{
Name: "feature",
RepoID: 2, // Different RepoID
Sha: "sha1",
},
},
},
{
PR: &models.PullRequest{
Base: &models.PRBranchInfo{
Name: "other",
Repo: &models.Repository{
Name: "other-pkg",
Owner: &models.User{UserName: "test-org"},
},
},
},
},
},
}
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("remote2", nil)
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), "fetch", "remote2", "sha1")
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), "checkout", "sha1")
err := processor.UpdatePrjGitPR(prset)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
t.Run("Standard update with rebase and force push", func(t *testing.T) {
prset := &common.PRSet{
Config: config,
BotUser: "bot",
PRs: []*common.PRInfo{
{
RemoteName: "origin",
PR: &models.PullRequest{
User: &models.User{UserName: "bot"},
Mergeable: false, // Triggers rebase
Base: &models.PRBranchInfo{
Name: "main",
RepoID: 1,
Repo: &models.Repository{
Name: "test-prj",
Owner: &models.User{UserName: "test-org"},
SSHURL: "url",
},
},
Head: &models.PRBranchInfo{
Name: "PR_branch",
RepoID: 1,
Sha: "old-head",
},
},
},
{
PR: &models.PullRequest{
State: "open",
Base: &models.PRBranchInfo{
Repo: &models.Repository{
Name: "pkg-a",
Owner: &models.User{UserName: "test-org"},
},
},
Head: &models.PRBranchInfo{Sha: "pkg-sha"},
},
},
},
}
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil)
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), "fetch", gomock.Any(), gomock.Any())
// Rebase expectations
mockGit.EXPECT().GitExec(gomock.Any(), "rebase", gomock.Any()).Return(nil)
// First call returns old-head, second returns new-head to trigger push
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("old-head", nil).Times(1)
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("new-head", nil).Times(1)
// SetSubmodulesToMatchPRSet expectations
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), "HEAD").Return(map[string]string{"pkg-a": "old-pkg-sha"}, nil)
// Catch all GitExec calls with any number of arguments up to 5
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitStatus(gomock.Any()).Return(nil, nil).AnyTimes()
// UpdatePullRequest expectation
gitea.EXPECT().UpdatePullRequest(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
err := processor.UpdatePrjGitPR(prset)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
t.Run("isPrTitleSame logic", func(t *testing.T) {
longTitle := strings.Repeat("a", 251) + "..."
prset := &common.PRSet{
Config: config,
BotUser: "bot",
PRs: []*common.PRInfo{
{
RemoteName: "origin",
PR: &models.PullRequest{
User: &models.User{UserName: "bot"},
Title: longTitle,
Base: &models.PRBranchInfo{
Name: "main",
RepoID: 1,
Repo: &models.Repository{
Name: "test-prj",
Owner: &models.User{UserName: "test-org"},
},
},
Head: &models.PRBranchInfo{
Name: "PR_branch",
RepoID: 1,
Sha: "head",
},
},
},
{
PR: &models.PullRequest{
State: "open",
Base: &models.PRBranchInfo{
Repo: &models.Repository{
Name: "pkg-a",
Owner: &models.User{UserName: "test-org"},
},
},
Head: &models.PRBranchInfo{Sha: "pkg-sha"},
},
},
},
}
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil)
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), "fetch", gomock.Any(), gomock.Any())
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), "HEAD").Return(map[string]string{"pkg-a": "pkg-sha"}, nil)
// mockGit.EXPECT().GitExec(...) not called because no push (headCommit == newHeadCommit)
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
err := processor.UpdatePrjGitPR(prset)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
func TestCreatePRjGitPR_Integration(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
mockGit := mock_common.NewMockGit(ctl)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
processor := &PRProcessor{
config: config,
git: mockGit,
}
prset := &common.PRSet{
Config: config,
PRs: []*common.PRInfo{
{
PR: &models.PullRequest{
State: "open",
Base: &models.PRBranchInfo{
Repo: &models.Repository{Name: "pkg-a", Owner: &models.User{UserName: "test-org"}},
},
Head: &models.PRBranchInfo{Sha: "pkg-sha"},
},
},
},
}
t.Run("Create new project PR with label", func(t *testing.T) {
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head-sha", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{}, nil).AnyTimes()
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitStatus(gomock.Any()).Return(nil, nil).AnyTimes()
prjPR := &models.PullRequest{
Index: 10,
Base: &models.PRBranchInfo{
Name: "main",
RepoID: 1,
Repo: &models.Repository{Name: "test-prj", Owner: &models.User{UserName: "test-org"}},
},
Head: &models.PRBranchInfo{
Sha: "prj-head-sha",
},
}
gitea.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(&models.Repository{Owner: &models.User{UserName: "test-org"}}, nil).AnyTimes()
// CreatePullRequestIfNotExist returns isNew=true
gitea.EXPECT().CreatePullRequestIfNotExist(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(prjPR, nil, true).AnyTimes()
// Expect SetLabels to be called for new PR
gitea.EXPECT().SetLabels("test-org", gomock.Any(), int64(10), gomock.Any()).Return(nil, nil).AnyTimes()
gitea.EXPECT().UpdatePullRequest(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any()).Return().AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any()).Return().AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return().AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return().AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return().AnyTimes()
err := processor.CreatePRjGitPR("PR_branch", prset)
if err != nil {
t.Errorf("CreatePRjGitPR failed: %v", err)
}
})
}
func TestMultiPackagePRSet(t *testing.T) {
GitAuthor = "Bot" // Ensure non-empty author
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
prset := &common.PRSet{
Config: config,
}
for i := 1; i <= 5; i++ {
name := fmt.Sprintf("pkg-%d", i)
prset.PRs = append(prset.PRs, &common.PRInfo{
PR: &models.PullRequest{
Index: int64(i),
State: "open",
Base: &models.PRBranchInfo{
Ref: "main",
Repo: &models.Repository{Name: name, Owner: &models.User{UserName: "test-org"}},
},
},
})
}
GitAuthor = "Bot"
title, desc := PrjGitDescription(prset)
// PrjGitDescription generates title like "Forwarded PRs: pkg-1, pkg-2, pkg-3, pkg-4, pkg-5"
for i := 1; i <= 5; i++ {
name := fmt.Sprintf("pkg-%d", i)
if !strings.Contains(title, name) {
t.Errorf("Title missing package %s: %s", name, title)
}
}
for i := 1; i <= 5; i++ {
ref := fmt.Sprintf("PR: test-org/pkg-%d!%d", i, i)
if !strings.Contains(desc, ref) {
t.Errorf("Description missing reference %s", ref)
}
}
}
func TestPRProcessor_Process_EdgeCases(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
mockGit := mock_common.NewMockGit(ctl)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
CurrentUser = &models.User{UserName: "bot"}
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
processor := &PRProcessor{
config: config,
git: mockGit,
}
t.Run("Merged project PR - update downstream", func(t *testing.T) {
prjPR := &models.PullRequest{
State: "closed",
HasMerged: true,
Index: 100,
Base: &models.PRBranchInfo{
Name: "main",
Repo: &models.Repository{Name: "test-prj", Owner: &models.User{UserName: "test-org"}, SSHURL: "url"},
},
Head: &models.PRBranchInfo{Name: "PR_branch"},
}
pkgPR := &models.PullRequest{
State: "open",
Index: 1,
Base: &models.PRBranchInfo{Name: "main", Repo: &models.Repository{Name: "pkg-a", Owner: &models.User{UserName: "test-org"}}},
Head: &models.PRBranchInfo{Sha: "pkg-sha"},
}
prset := &common.PRSet{
BotUser: "bot",
Config: config,
PRs: []*common.PRInfo{
{PR: prjPR},
{PR: pkgPR},
},
}
_ = prset // Suppress unused for now if it's really unused, but it's likely used by common.FetchPRSet internally if we weren't mocking everything
// Mock expectations for Process setup
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(prjPR, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
// Mock maintainership file calls
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
// Mock expectations for the merged path
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil)
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{"pkg-a": "old-sha"}, nil).AnyTimes()
gitea.EXPECT().GetRecentCommits(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.Commit{{SHA: "pkg-sha"}}, nil).AnyTimes()
// Downstream update expectations
gitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
gitea.EXPECT().ManualMergePR(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
err := processor.Process(pkgPR)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
t.Run("Superfluous PR - close it", func(t *testing.T) {
prjPR := &models.PullRequest{
State: "open",
Index: 100,
User: &models.User{UserName: "bot"},
Body: "Forwarded PRs: \n", // No PRs linked
Base: &models.PRBranchInfo{
Name: "main",
Repo: &models.Repository{Name: "test-prj", Owner: &models.User{UserName: "test-org"}},
},
Head: &models.PRBranchInfo{Name: "PR_branch", Sha: "head-sha"},
MergeBase: "base-sha",
}
prset := &common.PRSet{
BotUser: "bot",
Config: config,
PRs: []*common.PRInfo{
{PR: prjPR},
},
}
_ = prset
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(prjPR, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(&models.Repository{Owner: &models.User{UserName: "test-org"}}, nil).AnyTimes()
// Mock maintainership file calls
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
// Standard update calls within Process
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), "fetch", gomock.Any(), gomock.Any()).AnyTimes()
mockGit.EXPECT().GitBranchHead(gomock.Any(), gomock.Any()).Return("head-sha", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{}, nil).AnyTimes()
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitStatus(gomock.Any()).Return(nil, nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
// Diff check for superfluous
mockGit.EXPECT().GitDiff(gomock.Any(), gomock.Any(), gomock.Any()).Return("", nil).AnyTimes()
// Expectations for closing
gitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
gitea.EXPECT().UpdatePullRequest(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
err := processor.Process(prjPR)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
func TestVerifyRepositoryConfiguration(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
repo := &models.Repository{
Name: "test-repo",
Owner: &models.User{
UserName: "test-user",
},
AutodetectManualMerge: true,
AllowManualMerge: true,
}
t.Run("Config already correct", func(t *testing.T) {
err := verifyRepositoryConfiguration(repo)
if err != nil {
t.Errorf("Expected nil error, got %v", err)
}
})
t.Run("Config incorrect - trigger update", func(t *testing.T) {
repo.AllowManualMerge = false
gitea.EXPECT().SetRepoOptions("test-user", "test-repo", true).Return(&models.Repository{}, nil)
err := verifyRepositoryConfiguration(repo)
if err != nil {
t.Errorf("Expected nil error, got %v", err)
}
})
t.Run("Update failure", func(t *testing.T) {
repo.AllowManualMerge = false
expectedErr := errors.New("update failed")
gitea.EXPECT().SetRepoOptions("test-user", "test-repo", true).Return(nil, expectedErr)
err := verifyRepositoryConfiguration(repo)
if err != expectedErr {
t.Errorf("Expected %v, got %v", expectedErr, err)
}
})
}
func TestProcessFunc(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
mockGit := mock_common.NewMockGit(ctl)
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
reqProc := &RequestProcessor{
configuredRepos: map[string][]*common.AutogitConfig{
"test-org": {config},
},
}
modelPR := &models.PullRequest{
Index: 1,
Base: &models.PRBranchInfo{
Ref: "main",
Repo: &models.Repository{
Name: "test-repo",
DefaultBranch: "main",
Owner: &models.User{UserName: "test-org"},
},
},
}
t.Run("PullRequestWebhookEvent", func(t *testing.T) {
event := &common.PullRequestWebhookEvent{
Pull_Request: &common.PullRequest{
Number: 1,
Base: common.Head{
Ref: "main",
Repo: &common.Repository{
Name: "test-repo",
Owner: &common.Organization{
Username: "test-org",
},
},
},
},
}
gitea.EXPECT().GetPullRequest("test-org", "test-repo", int64(1)).Return(modelPR, nil).AnyTimes()
// AllocatePRProcessor and ProcesPullRequest calls inside
mockGitGen.EXPECT().CreateGitHandler(gomock.Any()).Return(mockGit, nil)
mockGit.EXPECT().GetPath().Return("/tmp").AnyTimes()
mockGit.EXPECT().Close().Return(nil)
// Expect Process calls (mocked via mockGit mostly)
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
err := reqProc.ProcessFunc(&common.Request{Data: event})
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
t.Run("IssueCommentWebhookEvent", func(t *testing.T) {
event := &common.IssueCommentWebhookEvent{
Issue: &common.IssueDetail{Number: 1},
Repository: &common.Repository{
Name: "test-repo",
Owner: &common.Organization{Username: "test-org"},
},
}
gitea.EXPECT().GetPullRequest("test-org", "test-repo", int64(1)).Return(modelPR, nil).AnyTimes()
mockGitGen.EXPECT().CreateGitHandler(gomock.Any()).Return(mockGit, nil)
mockGit.EXPECT().Close().Return(nil)
err := reqProc.ProcessFunc(&common.Request{Data: event})
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
t.Run("Recursion limit", func(t *testing.T) {
reqProc.recursive = 3
err := reqProc.ProcessFunc(&common.Request{})
if err != nil {
t.Errorf("Expected nil error on recursion limit, got %v", err)
}
if reqProc.recursive != 3 {
t.Errorf("Expected recursive to be 3, got %d", reqProc.recursive)
}
reqProc.recursive = 0 // Reset
})
t.Run("Invalid data format", func(t *testing.T) {
err := reqProc.ProcessFunc(&common.Request{Data: nil})
if err == nil || !strings.Contains(err.Error(), "Invalid data format") {
t.Errorf("Expected 'Invalid data format' error, got %v", err)
}
})
}

View File

@@ -1,7 +1,6 @@
package main
import (
"errors"
"fmt"
"math/rand"
"path"
@@ -11,7 +10,6 @@ import (
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
"src.opensuse.org/autogits/workflow-pr/interfaces"
)
type DefaultStateChecker struct {
@@ -19,11 +17,11 @@ type DefaultStateChecker struct {
checkOnStart bool
checkInterval time.Duration
processor *RequestProcessor
i interfaces.StateChecker
processor PullRequestProcessor
i StateChecker
}
func CreateDefaultStateChecker(checkOnStart bool, processor *RequestProcessor, gitea common.Gitea, interval time.Duration) *DefaultStateChecker {
func CreateDefaultStateChecker(checkOnStart bool, processor PullRequestProcessor, gitea common.Gitea, interval time.Duration) *DefaultStateChecker {
var s = &DefaultStateChecker{
checkInterval: interval,
checkOnStart: checkOnStart,
@@ -43,10 +41,19 @@ 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})
}
func PrjGitSubmoduleCheck(config *common.AutogitConfig, git common.Git, repo string, submodules map[string]string) (prsToProcess []*interfaces.PRToProcess, err error) {
func PrjGitSubmoduleCheck(config *common.AutogitConfig, git common.Git, repo string, submodules map[string]string) (prsToProcess []*PRToProcess, err error) {
nextSubmodule:
for sub, commitID := range submodules {
common.LogDebug(" + checking", sub, commitID)
@@ -66,7 +73,7 @@ nextSubmodule:
branch = repo.DefaultBranch
}
prsToProcess = append(prsToProcess, &interfaces.PRToProcess{
prsToProcess = append(prsToProcess, &PRToProcess{
Org: config.Organization,
Repo: submoduleName,
Branch: branch,
@@ -109,7 +116,7 @@ nextSubmodule:
return prsToProcess, nil
}
func (s *DefaultStateChecker) VerifyProjectState(config *common.AutogitConfig) ([]*interfaces.PRToProcess, error) {
func (s *DefaultStateChecker) VerifyProjectState(config *common.AutogitConfig) ([]*PRToProcess, error) {
defer func() {
if r := recover(); r != nil {
common.LogError("panic caught")
@@ -120,7 +127,7 @@ func (s *DefaultStateChecker) VerifyProjectState(config *common.AutogitConfig) (
}
}()
prsToProcess := []*interfaces.PRToProcess{}
prsToProcess := []*PRToProcess{}
prjGitOrg, prjGitRepo, prjGitBranch := config.GetPrjGit()
common.LogInfo(" checking", prjGitOrg+"/"+prjGitRepo+"#"+prjGitBranch)
@@ -140,7 +147,7 @@ func (s *DefaultStateChecker) VerifyProjectState(config *common.AutogitConfig) (
_, err = git.GitClone(prjGitRepo, prjGitBranch, repo.SSHURL)
common.PanicOnError(err)
prsToProcess = append(prsToProcess, &interfaces.PRToProcess{
prsToProcess = append(prsToProcess, &PRToProcess{
Org: prjGitOrg,
Repo: prjGitRepo,
Branch: prjGitBranch,
@@ -148,10 +155,11 @@ func (s *DefaultStateChecker) VerifyProjectState(config *common.AutogitConfig) (
submodules, err := git.GitSubmoduleList(prjGitRepo, "HEAD")
// forward any package-gits referred by the project git, but don't go back
return PrjGitSubmoduleCheck(config, git, prjGitRepo, submodules)
subPrs, err := PrjGitSubmoduleCheck(config, git, prjGitRepo, submodules)
return append(prsToProcess, subPrs...), err
}
func (s *DefaultStateChecker) CheckRepos() error {
func (s *DefaultStateChecker) CheckRepos() {
defer func() {
if r := recover(); r != nil {
common.LogError("panic caught")
@@ -161,9 +169,9 @@ func (s *DefaultStateChecker) CheckRepos() error {
common.LogError(string(debug.Stack()))
}
}()
errorList := make([]error, 0, 10)
for org, configs := range s.processor.configuredRepos {
processor := s.processor.(*RequestProcessor)
for org, configs := range processor.configuredRepos {
for _, config := range configs {
if s.checkInterval > 0 {
sleepInterval := (s.checkInterval - s.checkInterval/2) + time.Duration(rand.Int63n(int64(s.checkInterval)))
@@ -175,12 +183,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 +201,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 {

View File

@@ -0,0 +1,338 @@
package main
import (
"errors"
"strings"
"testing"
"time"
"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 TestPrjGitSubmoduleCheck(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit := mock_common.NewMockGit(ctl)
Gitea = gitea
config := &common.AutogitConfig{
Organization: "test-org",
Branch: "main",
}
t.Run("Submodule up to date", func(t *testing.T) {
submodules := map[string]string{
"pkg-a": "sha-1",
}
gitea.EXPECT().GetRecentCommits("test-org", "pkg-a", "main", int64(10)).Return([]*models.Commit{
{SHA: "sha-1"},
}, nil)
prs, err := PrjGitSubmoduleCheck(config, mockGit, "prj-repo", submodules)
if err != nil {
t.Fatalf("PrjGitSubmoduleCheck failed: %v", err)
}
if len(prs) != 1 || prs[0].Repo != "pkg-a" {
t.Errorf("Expected 1 PR to process for pkg-a, got %v", prs)
}
})
t.Run("Submodule behind branch", func(t *testing.T) {
submodules := map[string]string{
"pkg-a": "sha-old",
}
// sha-old is the second commit, meaning it's behind the head (sha-new)
gitea.EXPECT().GetRecentCommits("test-org", "pkg-a", "main", int64(10)).Return([]*models.Commit{
{SHA: "sha-new"},
{SHA: "sha-old"},
}, nil)
prs, err := PrjGitSubmoduleCheck(config, mockGit, "prj-repo", submodules)
if err != nil {
t.Fatalf("PrjGitSubmoduleCheck failed: %v", err)
}
if len(prs) != 1 || prs[0].Repo != "pkg-a" {
t.Errorf("Expected 1 PR to process for pkg-a, got %v", prs)
}
})
t.Run("Submodule with new commits - advance branch", func(t *testing.T) {
submodules := map[string]string{
"pkg-a": "sha-very-new",
}
// sha-very-new is NOT in recent commits
gitea.EXPECT().GetRecentCommits("test-org", "pkg-a", "main", int64(10)).Return([]*models.Commit{
{SHA: "sha-new"},
{SHA: "sha-old"},
}, nil)
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitExecWithOutputOrPanic(gomock.Any(), "rev-list", gomock.Any(), gomock.Any()).Return("commit-1\n").AnyTimes()
mockGit.EXPECT().GitExecWithOutputOrPanic(gomock.Any(), "remote", gomock.Any(), gomock.Any(), gomock.Any()).Return("https://src.opensuse.org/test-org/pkg-a.git\n").AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any()).Return().AnyTimes()
prs, err := PrjGitSubmoduleCheck(config, mockGit, "prj-repo", submodules)
if err != nil {
t.Fatalf("PrjGitSubmoduleCheck failed: %v", err)
}
if len(prs) != 1 || prs[0].Repo != "pkg-a" {
t.Errorf("Expected 1 PR to process for pkg-a, got %v", prs)
}
})
}
func TestPrjGitSubmoduleCheck_Failures(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit := mock_common.NewMockGit(ctl)
Gitea = gitea
config := &common.AutogitConfig{
Organization: "test-org",
Branch: "main",
}
t.Run("GetRecentCommits failure", func(t *testing.T) {
submodules := map[string]string{"pkg-a": "sha-1"}
gitea.EXPECT().GetRecentCommits("test-org", "pkg-a", "main", int64(10)).Return(nil, errors.New("gitea error"))
_, err := PrjGitSubmoduleCheck(config, mockGit, "prj-repo", submodules)
if err == nil || !strings.Contains(err.Error(), "Error fetching recent commits") {
t.Errorf("Expected gitea error, got %v", err)
}
})
t.Run("SSH translation failure", func(t *testing.T) {
submodules := map[string]string{"pkg-a": "sha-new"}
gitea.EXPECT().GetRecentCommits("test-org", "pkg-a", "main", int64(10)).Return([]*models.Commit{{SHA: "sha-old"}}, nil)
mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), gomock.Any()).Return().AnyTimes()
mockGit.EXPECT().GitExecWithOutputOrPanic(gomock.Any(), "rev-list", gomock.Any(), gomock.Any()).Return("commit-1\n").AnyTimes()
// Return invalid URL that cannot be translated to SSH
mockGit.EXPECT().GitExecWithOutputOrPanic(gomock.Any(), "remote", gomock.Any(), gomock.Any(), gomock.Any()).Return("not-a-url").AnyTimes()
_, err := PrjGitSubmoduleCheck(config, mockGit, "prj-repo", submodules)
if err == nil || !strings.Contains(err.Error(), "Cannot traslate HTTPS git URL to SSH_URL") {
t.Errorf("Expected SSH translation error, got %v", err)
}
})
}
func TestPullRequestToEventState(t *testing.T) {
tests := []struct {
state models.StateType
expected string
}{
{"open", "opened"},
{"closed", "closed"},
{"merged", "merged"},
}
for _, tt := range tests {
if got := pullRequestToEventState(tt.state); got != tt.expected {
t.Errorf("pullRequestToEventState(%v) = %v; want %v", tt.state, got, tt.expected)
}
}
}
func TestDefaultStateChecker_ProcessPR(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
mockGit := mock_common.NewMockGit(ctl)
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
checker := CreateDefaultStateChecker(false, nil, gitea, time.Duration(0))
pr := &models.PullRequest{
Index: 1,
Base: &models.PRBranchInfo{
Ref: "main",
Repo: &models.Repository{
Name: "test-repo",
DefaultBranch: "main",
Owner: &models.User{UserName: "test-org"},
},
},
}
mockGitGen.EXPECT().CreateGitHandler(gomock.Any()).Return(mockGit, nil)
mockGit.EXPECT().GetPath().Return("/tmp").AnyTimes()
mockGit.EXPECT().Close().Return(nil)
// Expectations for ProcesPullRequest
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(pr, nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
err := checker.ProcessPR(pr, config)
if err != nil {
t.Errorf("ProcessPR failed: %v", err)
}
}
func TestDefaultStateChecker_VerifyProjectState(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
mockGit := mock_common.NewMockGit(ctl)
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
checker := CreateDefaultStateChecker(false, nil, gitea, 0)
t.Run("VerifyProjectState success", func(t *testing.T) {
mockGitGen.EXPECT().CreateGitHandler("test-org").Return(mockGit, nil)
mockGit.EXPECT().GetPath().Return("/tmp").AnyTimes()
mockGit.EXPECT().Close().Return(nil)
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), "test-org", "test-prj").Return(&models.Repository{SSHURL: "url"}, nil)
mockGit.EXPECT().GitClone("test-prj", "main", "url").Return("origin", nil)
mockGit.EXPECT().GitSubmoduleList("test-prj", "HEAD").Return(map[string]string{"pkg-a": "sha-1"}, nil)
// PrjGitSubmoduleCheck call inside
gitea.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(&models.Repository{DefaultBranch: "main"}, nil).AnyTimes()
// Return commits where sha-1 is NOT present
gitea.EXPECT().GetRecentCommits("test-org", "pkg-a", "main", int64(10)).Return([]*models.Commit{
{SHA: "sha-new"},
}, nil).AnyTimes()
// rev-list returns empty string, so no new commits on branch relative to submodule commitID
mockGit.EXPECT().GitExecWithOutputOrPanic(gomock.Any(), "rev-list", gomock.Any(), "sha-1").Return("").AnyTimes()
// And ensure submodule update is called
mockGit.EXPECT().GitExecOrPanic(gomock.Any(), "submodule", "update", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return().AnyTimes()
prs, err := checker.VerifyProjectState(config)
if err != nil {
t.Errorf("VerifyProjectState failed: %v", err)
}
// Expect project git + pkg-a
if len(prs) != 2 {
t.Errorf("Expected 2 PRs to process, got %d", len(prs))
}
})
t.Run("VerifyProjectState failure - CreateRepository", func(t *testing.T) {
mockGitGen.EXPECT().CreateGitHandler("test-org").Return(mockGit, nil)
mockGit.EXPECT().GetPath().Return("/tmp").AnyTimes()
mockGit.EXPECT().Close().Return(nil)
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), "test-org", "test-prj").Return(nil, errors.New("gitea error"))
_, err := checker.VerifyProjectState(config)
if err == nil || !strings.Contains(err.Error(), "Error fetching or creating") {
t.Errorf("Expected gitea error, got %v", err)
}
})
}
func TestDefaultStateChecker_CheckRepos(t *testing.T) {
ctl := gomock.NewController(t)
defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea
mockGit := mock_common.NewMockGit(ctl)
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
GitHandler = mockGitGen
config := &common.AutogitConfig{
Organization: "test-org",
GitProjectName: "test-prj#main",
}
reqProc := &RequestProcessor{
configuredRepos: map[string][]*common.AutogitConfig{
"test-org": {config},
},
}
checker := CreateDefaultStateChecker(false, nil, gitea, 0)
checker.processor = reqProc
t.Run("CheckRepos success with PRs", func(t *testing.T) {
// Mock VerifyProjectState results
// TODO: fix below
// Since we can't easily mock the internal call s.i.VerifyProjectState because s.i is the checker itself
// and VerifyProjectState is not a separate interface method in repo_check.go (it is, but used internally).
// Actually DefaultStateChecker implements i (StateChecker interface).
mockGitGen.EXPECT().CreateGitHandler("test-org").Return(mockGit, nil).AnyTimes()
mockGit.EXPECT().GetPath().Return("/tmp").AnyTimes()
mockGit.EXPECT().Close().Return(nil).AnyTimes()
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "url"}, nil).AnyTimes()
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{}, nil).AnyTimes()
// GetRecentPullRequests for the project git
gitea.EXPECT().GetRecentPullRequests("test-org", "test-prj", "main").Return([]*models.PullRequest{
{Index: 1, Base: &models.PRBranchInfo{Repo: &models.Repository{Name: "test-prj", Owner: &models.User{UserName: "test-org"}}}},
}, nil).AnyTimes()
// ProcessPR calls for the found PR
gitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.PullRequest{
Index: 1,
Base: &models.PRBranchInfo{
Ref: "main",
Repo: &models.Repository{Name: "test-prj", Owner: &models.User{UserName: "test-org"}},
},
}, nil).AnyTimes()
gitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{}, nil).AnyTimes()
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().FetchMaintainershipDirFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, "", nil).AnyTimes()
gitea.EXPECT().SetRepoOptions(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
checker.CheckRepos()
})
t.Run("CheckRepos failure - GetRecentPullRequests", func(t *testing.T) {
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{SSHURL: "url"}, nil).AnyTimes()
mockGit.EXPECT().GitClone(gomock.Any(), gomock.Any(), gomock.Any()).Return("origin", nil).AnyTimes()
mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(map[string]string{}, nil).AnyTimes()
gitea.EXPECT().GetRecentPullRequests("test-org", "test-prj", "main").Return(nil, errors.New("gitea error")).AnyTimes()
checker.CheckRepos()
// Should log error and continue (no panic)
})
}

View File

@@ -10,7 +10,6 @@ import (
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
mock_main "src.opensuse.org/autogits/workflow-pr/mock"
)
func TestRepoCheck(t *testing.T) {
@@ -22,16 +21,15 @@ func TestRepoCheck(t *testing.T) {
t.Run("Consistency Check On Start", func(t *testing.T) {
c := CreateDefaultStateChecker(true, nil, nil, 100)
ctl := gomock.NewController(t)
state := mock_main.NewMockStateChecker(ctl)
state := NewMockStateChecker(ctl)
c.i = state
state.EXPECT().CheckRepos().Do(func() error {
state.EXPECT().CheckRepos().Do(func() {
// only checkOnStart has checkInterval = 0
if c.checkInterval != 0 {
t.Fail()
}
c.exitCheckLoop = true
return nil
})
c.ConsistencyCheckProcess()
@@ -43,11 +41,11 @@ func TestRepoCheck(t *testing.T) {
t.Run("No consistency Check On Start", func(t *testing.T) {
c := CreateDefaultStateChecker(true, nil, nil, 100)
ctl := gomock.NewController(t)
state := mock_main.NewMockStateChecker(ctl)
state := NewMockStateChecker(ctl)
c.i = state
nCalls := 10
state.EXPECT().CheckRepos().Do(func() error {
state.EXPECT().CheckRepos().Do(func() {
// only checkOnStart has checkInterval = 0
if c.checkInterval != 100 {
t.Fail()
@@ -57,7 +55,6 @@ func TestRepoCheck(t *testing.T) {
if nCalls == 0 {
c.exitCheckLoop = true
}
return nil
}).Times(nCalls)
c.checkOnStart = false
@@ -66,8 +63,9 @@ func TestRepoCheck(t *testing.T) {
t.Run("CheckRepos() calls CheckProjectState() for each project", func(t *testing.T) {
ctl := gomock.NewController(t)
state := mock_main.NewMockStateChecker(ctl)
state := NewMockStateChecker(ctl)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
config1 := &common.AutogitConfig{
GitProjectName: "git_repo1",
@@ -97,15 +95,14 @@ func TestRepoCheck(t *testing.T) {
state.EXPECT().VerifyProjectState(configs.configuredRepos["repo2_org"][0])
state.EXPECT().VerifyProjectState(configs.configuredRepos["repo3_org"][0])
if err := c.CheckRepos(); err != nil {
t.Error(err)
}
c.CheckRepos()
})
t.Run("CheckRepos errors", func(t *testing.T) {
ctl := gomock.NewController(t)
state := mock_main.NewMockStateChecker(ctl)
state := NewMockStateChecker(ctl)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
config1 := &common.AutogitConfig{
GitProjectName: "git_repo1",
@@ -125,11 +122,7 @@ func TestRepoCheck(t *testing.T) {
err := errors.New("test error")
state.EXPECT().VerifyProjectState(configs.configuredRepos["repo1_org"][0]).Return(nil, err)
r := c.CheckRepos()
if !errors.Is(r, err) {
t.Error(err)
}
c.CheckRepos()
})
}
@@ -154,6 +147,7 @@ func TestVerifyProjectState(t *testing.T) {
t.Run("Project state with no PRs", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
git := &common.GitHandlerImpl{
GitCommiter: "TestCommiter",
@@ -177,11 +171,11 @@ func TestVerifyProjectState(t *testing.T) {
},
}
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), config1.GitProjectName).Return(&models.Repository{
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{
SSHURL: "./prj",
}, nil)
gitea.EXPECT().GetRecentPullRequests(org, "testRepo", "testing")
gitea.EXPECT().GetRecentCommits(org, "testRepo", "testing", gomock.Any())
}, nil).AnyTimes()
gitea.EXPECT().GetRecentPullRequests(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullRequest{}, nil).AnyTimes()
gitea.EXPECT().GetRecentCommits(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.Commit{}, nil).AnyTimes()
c := CreateDefaultStateChecker(false, configs, gitea, 0)
/*
@@ -199,7 +193,7 @@ func TestVerifyProjectState(t *testing.T) {
t.Run("Project state with 1 PRs that doesn't trigger updates", func(t *testing.T) {
ctl := gomock.NewController(t)
gitea := mock_common.NewMockGitea(ctl)
process := mock_main.NewMockPullRequestProcessor(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
git := &common.GitHandlerImpl{
GitCommiter: "TestCommiter",
@@ -223,11 +217,11 @@ func TestVerifyProjectState(t *testing.T) {
},
}
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), config1.GitProjectName).Return(&models.Repository{
gitea.EXPECT().CreateRepositoryIfNotExist(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Repository{
SSHURL: "./prj",
}, nil)
}, nil).AnyTimes()
gitea.EXPECT().GetRecentPullRequests(org, "testRepo", "testing").Return([]*models.PullRequest{
gitea.EXPECT().GetRecentPullRequests(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullRequest{
&models.PullRequest{
ID: 1234,
URL: "url here",
@@ -259,16 +253,16 @@ func TestVerifyProjectState(t *testing.T) {
},
},
},
}, nil)
}, nil).AnyTimes()
gitea.EXPECT().GetRecentCommits(org, "testRepo", "testing", gomock.Any())
gitea.EXPECT().GetRecentCommits(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.Commit{}, nil).AnyTimes()
c := CreateDefaultStateChecker(false, configs, gitea, 0)
/*
c.git = &testGit{
git: git,
}*/
process.EXPECT().Process(gomock.Any(), gomock.Any(), gomock.Any())
// process.EXPECT().Process(gomock.Any())
// c.processor.Opened = process
_, err := c.VerifyProjectState(configs.configuredRepos[org][0])

View File

@@ -0,0 +1,23 @@
package main
import (
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
)
//go:generate mockgen -source=state_checker.go -destination=mock_state_checker.go -typed -package main
type StateChecker interface {
VerifyProjectState(configs *common.AutogitConfig) ([]*PRToProcess, error)
CheckRepos()
ConsistencyCheckProcess() error
}
type PullRequestProcessor interface {
Process(req *models.PullRequest) error
}
type PRToProcess struct {
Org, Repo, Branch string
}

View File

@@ -0,0 +1,87 @@
package main
import (
"fmt"
"os/exec"
"path/filepath"
"testing"
"src.opensuse.org/autogits/common"
)
const LocalCMD = "---"
func gitExecs(t *testing.T, git *common.GitHandlerImpl, cmds [][]string) {
for _, cmd := range cmds {
if cmd[0] == LocalCMD {
command := exec.Command(cmd[2], cmd[3:]...)
command.Dir = filepath.Join(git.GitPath, cmd[1])
command.Stdin = nil
command.Env = append([]string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_1=protocol.file.allow", "GIT_CONFIG_VALUE_1=always"}, common.ExtraGitParams...)
_, err := command.CombinedOutput()
if err != nil {
t.Errorf(" *** error: %v\n", err)
}
} else {
git.GitExecOrPanic(cmd[0], cmd[1:]...)
}
}
}
func commandsForPackages(dir, prefix string, startN, endN int) [][]string {
commands := make([][]string, (endN-startN+2)*6)
if dir == "" {
dir = "."
}
cmdIdx := 0
for idx := startN; idx <= endN; idx++ {
pkgDir := fmt.Sprintf("%s%d", prefix, idx)
commands[cmdIdx+0] = []string{"", "init", "-q", "--object-format", "sha256", "-b", "testing", pkgDir}
commands[cmdIdx+1] = []string{LocalCMD, pkgDir, "/usr/bin/touch", "testFile"}
commands[cmdIdx+2] = []string{pkgDir, "add", "testFile"}
commands[cmdIdx+3] = []string{pkgDir, "commit", "-m", "added testFile"}
commands[cmdIdx+4] = []string{pkgDir, "config", "receive.denyCurrentBranch", "ignore"}
commands[cmdIdx+5] = []string{"prj", "submodule", "add", filepath.Join("..", pkgDir), filepath.Join(dir, pkgDir)}
cmdIdx += 6
}
// add all the submodules to the prj
commands[cmdIdx+0] = []string{"prj", "commit", "-a", "-m", "adding subpackages"}
return commands
}
func setupGitForTests(t *testing.T, git *common.GitHandlerImpl) {
common.ExtraGitParams = []string{
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=protocol.file.allow",
"GIT_CONFIG_VALUE_0=always",
"GIT_AUTHOR_NAME=testname",
"GIT_AUTHOR_EMAIL=test@suse.com",
"GIT_AUTHOR_DATE='2005-04-07T22:13:13'",
"GIT_COMMITTER_NAME=testname",
"GIT_COMMITTER_EMAIL=test@suse.com",
"GIT_COMMITTER_DATE='2005-04-07T22:13:13'",
}
gitExecs(t, git, [][]string{
{"", "init", "-q", "--object-format", "sha256", "-b", "testing", "prj"},
{"", "init", "-q", "--object-format", "sha256", "-b", "testing", "foo"},
{LocalCMD, "foo", "/usr/bin/touch", "file1"},
{"foo", "add", "file1"},
{"foo", "commit", "-m", "first commit"},
{"prj", "config", "receive.denyCurrentBranch", "ignore"},
{"prj", "submodule", "init"},
{"prj", "submodule", "add", "../foo", "testRepo"},
{"prj", "add", ".gitmodules", "testRepo"},
{"prj", "commit", "-m", "First instance"},
{"prj", "submodule", "deinit", "testRepo"},
{LocalCMD, "foo", "/usr/bin/touch", "file2"},
{"foo", "add", "file2"},
{"foo", "commit", "-m", "added file2"},
})
}