16 Commits

Author SHA256 Message Date
c2709e1894 fix unit tests and mocks
All checks were successful
go-generate-check / go-generate-check (push) Successful in 8s
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'
Some checks failed
go-generate-check / go-generate-check (push) Failing after 8s
2026-01-27 15:44:30 +01:00
59a47cd542 Merge branch 'pr-tests'
Some checks failed
go-generate-check / go-generate-check (push) Failing after 7s
2026-01-27 13:41:34 +01:00
a0c51657d4 pr: reset timeline cache when fetching PRSet
Some checks failed
go-generate-check / go-generate-check (pull_request) Failing after 8s
go-generate-check / go-generate-check (push) Failing after 23s
2026-01-26 15:34:46 +01:00
edd8c67fc9 obs-staging-bot: allow build-disabling repositories in the QA projects
Some checks failed
go-generate-check / go-generate-check (push) Failing after 25s
go-generate-check / go-generate-check (pull_request) Has been cancelled
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
Some checks failed
go-generate-check / go-generate-check (pull_request) Failing after 23s
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
Some checks failed
go-generate-check / go-generate-check (push) Failing after 22s
2026-01-19 13:41:05 +01:00
Antonello Tartamo
2f39fc9836 initial documentation review 2026-01-14 15:43:27 +01:00
38f4c44fd0 group-review: add more unit tests
Some checks failed
go-generate-check / go-generate-check (pull_request) Failing after 28s
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
25 changed files with 1356 additions and 724 deletions

View File

@@ -129,9 +129,6 @@ go build \
go build \ go build \
-C utils/hujson \ -C utils/hujson \
-buildmode=pie -buildmode=pie
go build \
-C utils/maintainer-update \
-buildmode=pie
go build \ go build \
-C gitea-events-rabbitmq-publisher \ -C gitea-events-rabbitmq-publisher \
-buildmode=pie -buildmode=pie
@@ -182,7 +179,6 @@ install -D -m0755 workflow-direct/workflow-direct
install -D -m0644 systemd/workflow-direct@.service %{buildroot}%{_unitdir}/workflow-direct@.service install -D -m0644 systemd/workflow-direct@.service %{buildroot}%{_unitdir}/workflow-direct@.service
install -D -m0755 workflow-pr/workflow-pr %{buildroot}%{_bindir}/workflow-pr install -D -m0755 workflow-pr/workflow-pr %{buildroot}%{_bindir}/workflow-pr
install -D -m0755 utils/hujson/hujson %{buildroot}%{_bindir}/hujson 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 %pre gitea-events-rabbitmq-publisher
%service_add_pre gitea-events-rabbitmq-publisher.service %service_add_pre gitea-events-rabbitmq-publisher.service
@@ -289,7 +285,6 @@ install -D -m0755 utils/maintainer-update/maintainer-update
%files utils %files utils
%license COPYING %license COPYING
%{_bindir}/hujson %{_bindir}/hujson
%{_bindir}/maintainer-update
%files workflow-direct %files workflow-direct
%license COPYING %license COPYING

View File

@@ -54,6 +54,7 @@ type ReviewGroup struct {
type QAConfig struct { type QAConfig struct {
Name string Name string
Origin string Origin string
BuildDisableRepos []string // which repos to build disable in the new project
} }
type Permissions struct { type Permissions struct {
@@ -91,6 +92,7 @@ type AutogitConfig struct {
NoProjectGitPR bool // do not automatically create project git PRs, just assign reviewers and assume somethign else creates the ProjectGit PR 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 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 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 type AutogitConfigs []*AutogitConfig
@@ -292,9 +294,9 @@ func (config *AutogitConfig) GetRemoteBranch() string {
} }
func (config *AutogitConfig) Label(label string) string { func (config *AutogitConfig) Label(label string) string {
if t, found := config.Labels[LabelKey(label)]; found { if t, found := config.Labels[LabelKey(label)]; found {
return t return t
} }
return label return label
} }

View File

@@ -76,6 +76,7 @@ type GiteaLabelSettter interface {
} }
type GiteaTimelineFetcher interface { type GiteaTimelineFetcher interface {
ResetTimelineCache(org, repo string, idx int64)
GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error)
} }
@@ -813,6 +814,17 @@ type TimelineCacheData struct {
var giteaTimelineCache map[string]TimelineCacheData = make(map[string]TimelineCacheData) var giteaTimelineCache map[string]TimelineCacheData = make(map[string]TimelineCacheData)
var giteaTimelineCacheMutex sync.RWMutex 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)
}
}
// returns timeline in reverse chronological create order // returns timeline in reverse chronological create order
func (gitea *GiteaTransport) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) { func (gitea *GiteaTransport) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) {
page := int64(1) page := int64(1)

View File

@@ -1,12 +1,10 @@
package common package common
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"slices" "slices"
"strings"
"src.opensuse.org/autogits/common/gitea-generated/client/repository" "src.opensuse.org/autogits/common/gitea-generated/client/repository"
"src.opensuse.org/autogits/common/gitea-generated/models" "src.opensuse.org/autogits/common/gitea-generated/models"
@@ -27,14 +25,13 @@ const ProjectFileKey = "_project"
type MaintainershipMap struct { type MaintainershipMap struct {
Data map[string][]string Data map[string][]string
IsDir bool IsDir bool
Config *AutogitConfig
FetchPackage func(string) ([]byte, error) FetchPackage func(string) ([]byte, error)
Raw []byte
} }
func ParseMaintainershipData(data []byte) (*MaintainershipMap, error) { func parseMaintainershipData(data []byte) (*MaintainershipMap, error) {
maintainers := &MaintainershipMap{ maintainers := &MaintainershipMap{
Data: make(map[string][]string), Data: make(map[string][]string),
Raw: data,
} }
if err := json.Unmarshal(data, &maintainers.Data); err != nil { if err := json.Unmarshal(data, &maintainers.Data); err != nil {
return nil, err return nil, err
@@ -43,7 +40,9 @@ func ParseMaintainershipData(data []byte) (*MaintainershipMap, error) {
return maintainers, nil 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) data, _, err := gitea.FetchMaintainershipDirFile(org, prjGit, branch, ProjectFileKey)
dir := true dir := true
if err != nil || data == nil { if err != nil || data == nil {
@@ -63,8 +62,9 @@ func FetchProjectMaintainershipData(gitea GiteaMaintainershipReader, org, prjGit
} }
} }
m, err := ParseMaintainershipData(data) m, err := parseMaintainershipData(data)
if m != nil { if m != nil {
m.Config = config
m.IsDir = dir m.IsDir = dir
m.FetchPackage = func(pkg string) ([]byte, error) { m.FetchPackage = func(pkg string) ([]byte, error) {
data, _, err := gitea.FetchMaintainershipDirFile(org, prjGit, branch, pkg) data, _, err := gitea.FetchMaintainershipDirFile(org, prjGit, branch, pkg)
@@ -153,7 +153,10 @@ func (data *MaintainershipMap) IsApproved(pkg string, reviews []*models.PullRevi
} }
LogDebug("Looking for review by:", reviewers) 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.") LogDebug("Submitter is maintainer. Approving.")
return true return true
} }
@@ -168,135 +171,13 @@ func (data *MaintainershipMap) IsApproved(pkg string, reviews []*models.PullRevi
return false 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 { func (data *MaintainershipMap) WriteMaintainershipFile(writer io.StringWriter) error {
if data.IsDir { if data.IsDir {
return fmt.Errorf("Not implemented") return fmt.Errorf("Not implemented")
} }
if len(data.Raw) > 0 {
if err := data.modifyInplace(writer); err == nil {
return nil
}
}
// Fallback to full write
writer.WriteString("{\n") writer.WriteString("{\n")
if d, ok := data.Data[""]; ok { if d, ok := data.Data[""]; ok {
eol := "," eol := ","
if len(data.Data) == 1 { if len(data.Data) == 1 {
@@ -307,12 +188,17 @@ func (data *MaintainershipMap) WriteMaintainershipFile(writer io.StringWriter) e
writer.WriteString(fmt.Sprintf(" \"\": %s%s\n", string(str), eol)) writer.WriteString(fmt.Sprintf(" \"\": %s%s\n", string(str), eol))
} }
keys := make([]string, 0, len(data.Data)) keys := make([]string, len(data.Data))
i := 0
for pkg := range data.Data { for pkg := range data.Data {
if pkg == "" { if pkg == "" {
continue continue
} }
keys = append(keys, pkg) keys[i] = pkg
i++
}
if len(keys) >= i {
keys = slices.Delete(keys, i, len(keys))
} }
slices.Sort(keys) slices.Sort(keys)
for i, pkg := range keys { for i, pkg := range keys {

View File

@@ -13,10 +13,10 @@ import (
) )
func TestMaintainership(t *testing.T) { func TestMaintainership(t *testing.T) {
config := common.AutogitConfig{ config := &common.AutogitConfig{
Branch: "bar", Branch: "bar",
Organization: "foo", Organization: "foo",
GitProjectName: common.DefaultGitPrj, GitProjectName: common.DefaultGitPrj + "#bar",
} }
packageTests := []struct { packageTests := []struct {
@@ -141,7 +141,7 @@ func TestMaintainership(t *testing.T) {
notFoundError := repository.NewRepoGetContentsNotFound() notFoundError := repository.NewRepoGetContentsNotFound()
for _, test := range packageTests { for _, test := range packageTests {
runTests := func(t *testing.T, mi common.GiteaMaintainershipReader) { 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 err != nil && !test.otherError {
if test.maintainersFileErr == nil { if test.maintainersFileErr == nil {
t.Fatal("Unexpected error recieved", err) t.Fatal("Unexpected error recieved", err)
@@ -208,7 +208,6 @@ func TestMaintainershipFileWrite(t *testing.T) {
name string name string
is_dir bool is_dir bool
maintainers map[string][]string maintainers map[string][]string
raw []byte
expected_output string expected_output string
expected_error error expected_error error
}{ }{
@@ -232,43 +231,6 @@ func TestMaintainershipFileWrite(t *testing.T) {
}, },
expected_output: "{\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 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 { for _, test := range tests {
@@ -277,7 +239,6 @@ func TestMaintainershipFileWrite(t *testing.T) {
data := common.MaintainershipMap{ data := common.MaintainershipMap{
Data: test.maintainers, Data: test.maintainers,
IsDir: test.is_dir, IsDir: test.is_dir,
Raw: test.raw,
} }
if err := data.WriteMaintainershipFile(&b); err != test.expected_error { if err := data.WriteMaintainershipFile(&b); err != test.expected_error {
@@ -287,7 +248,47 @@ func TestMaintainershipFileWrite(t *testing.T) {
output := b.String() output := b.String()
if test.expected_output != output { if test.expected_output != output {
t.Fatalf("unexpected output:\n%q\nExpecting:\n%q", output, test.expected_output) t.Fatal("unexpected output:", output, "Expecting:", 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")
} }
}) })
} }

View File

@@ -207,6 +207,42 @@ func (c *MockGiteaTimelineFetcherGetTimelineCall) DoAndReturn(f func(string, str
return c return c
} }
// ResetTimelineCache mocks base method.
func (m *MockGiteaTimelineFetcher) ResetTimelineCache(org, repo string, idx int64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "ResetTimelineCache", org, repo, idx)
}
// ResetTimelineCache indicates an expected call of ResetTimelineCache.
func (mr *MockGiteaTimelineFetcherMockRecorder) ResetTimelineCache(org, repo, idx any) *MockGiteaTimelineFetcherResetTimelineCacheCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetTimelineCache", reflect.TypeOf((*MockGiteaTimelineFetcher)(nil).ResetTimelineCache), org, repo, idx)
return &MockGiteaTimelineFetcherResetTimelineCacheCall{Call: call}
}
// MockGiteaTimelineFetcherResetTimelineCacheCall wrap *gomock.Call
type MockGiteaTimelineFetcherResetTimelineCacheCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaTimelineFetcherResetTimelineCacheCall) Return() *MockGiteaTimelineFetcherResetTimelineCacheCall {
c.Call = c.Call.Return()
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaTimelineFetcherResetTimelineCacheCall) Do(f func(string, string, int64)) *MockGiteaTimelineFetcherResetTimelineCacheCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaTimelineFetcherResetTimelineCacheCall) DoAndReturn(f func(string, string, int64)) *MockGiteaTimelineFetcherResetTimelineCacheCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGiteaComment is a mock of GiteaComment interface. // MockGiteaComment is a mock of GiteaComment interface.
type MockGiteaComment struct { type MockGiteaComment struct {
ctrl *gomock.Controller ctrl *gomock.Controller
@@ -703,6 +739,42 @@ func (c *MockGiteaPRTimelineReviewFetcherGetTimelineCall) DoAndReturn(f func(str
return c return c
} }
// ResetTimelineCache mocks base method.
func (m *MockGiteaPRTimelineReviewFetcher) ResetTimelineCache(org, repo string, idx int64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "ResetTimelineCache", org, repo, idx)
}
// ResetTimelineCache indicates an expected call of ResetTimelineCache.
func (mr *MockGiteaPRTimelineReviewFetcherMockRecorder) ResetTimelineCache(org, repo, idx any) *MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetTimelineCache", reflect.TypeOf((*MockGiteaPRTimelineReviewFetcher)(nil).ResetTimelineCache), org, repo, idx)
return &MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall{Call: call}
}
// MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall wrap *gomock.Call
type MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall) Return() *MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall {
c.Call = c.Call.Return()
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall) Do(f func(string, string, int64)) *MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall) DoAndReturn(f func(string, string, int64)) *MockGiteaPRTimelineReviewFetcherResetTimelineCacheCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGiteaCommitFetcher is a mock of GiteaCommitFetcher interface. // MockGiteaCommitFetcher is a mock of GiteaCommitFetcher interface.
type MockGiteaCommitFetcher struct { type MockGiteaCommitFetcher struct {
ctrl *gomock.Controller ctrl *gomock.Controller
@@ -994,6 +1066,42 @@ func (c *MockGiteaReviewTimelineFetcherGetTimelineCall) DoAndReturn(f func(strin
return c return c
} }
// ResetTimelineCache mocks base method.
func (m *MockGiteaReviewTimelineFetcher) ResetTimelineCache(org, repo string, idx int64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "ResetTimelineCache", org, repo, idx)
}
// ResetTimelineCache indicates an expected call of ResetTimelineCache.
func (mr *MockGiteaReviewTimelineFetcherMockRecorder) ResetTimelineCache(org, repo, idx any) *MockGiteaReviewTimelineFetcherResetTimelineCacheCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetTimelineCache", reflect.TypeOf((*MockGiteaReviewTimelineFetcher)(nil).ResetTimelineCache), org, repo, idx)
return &MockGiteaReviewTimelineFetcherResetTimelineCacheCall{Call: call}
}
// MockGiteaReviewTimelineFetcherResetTimelineCacheCall wrap *gomock.Call
type MockGiteaReviewTimelineFetcherResetTimelineCacheCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaReviewTimelineFetcherResetTimelineCacheCall) Return() *MockGiteaReviewTimelineFetcherResetTimelineCacheCall {
c.Call = c.Call.Return()
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaReviewTimelineFetcherResetTimelineCacheCall) Do(f func(string, string, int64)) *MockGiteaReviewTimelineFetcherResetTimelineCacheCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaReviewTimelineFetcherResetTimelineCacheCall) DoAndReturn(f func(string, string, int64)) *MockGiteaReviewTimelineFetcherResetTimelineCacheCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGiteaPRChecker is a mock of GiteaPRChecker interface. // MockGiteaPRChecker is a mock of GiteaPRChecker interface.
type MockGiteaPRChecker struct { type MockGiteaPRChecker struct {
ctrl *gomock.Controller ctrl *gomock.Controller
@@ -1215,6 +1323,42 @@ func (c *MockGiteaPRCheckerGetTimelineCall) DoAndReturn(f func(string, string, i
return c return c
} }
// ResetTimelineCache mocks base method.
func (m *MockGiteaPRChecker) ResetTimelineCache(org, repo string, idx int64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "ResetTimelineCache", org, repo, idx)
}
// ResetTimelineCache indicates an expected call of ResetTimelineCache.
func (mr *MockGiteaPRCheckerMockRecorder) ResetTimelineCache(org, repo, idx any) *MockGiteaPRCheckerResetTimelineCacheCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetTimelineCache", reflect.TypeOf((*MockGiteaPRChecker)(nil).ResetTimelineCache), org, repo, idx)
return &MockGiteaPRCheckerResetTimelineCacheCall{Call: call}
}
// MockGiteaPRCheckerResetTimelineCacheCall wrap *gomock.Call
type MockGiteaPRCheckerResetTimelineCacheCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaPRCheckerResetTimelineCacheCall) Return() *MockGiteaPRCheckerResetTimelineCacheCall {
c.Call = c.Call.Return()
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaPRCheckerResetTimelineCacheCall) Do(f func(string, string, int64)) *MockGiteaPRCheckerResetTimelineCacheCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaPRCheckerResetTimelineCacheCall) DoAndReturn(f func(string, string, int64)) *MockGiteaPRCheckerResetTimelineCacheCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockGiteaReviewFetcherAndRequesterAndUnrequester is a mock of GiteaReviewFetcherAndRequesterAndUnrequester interface. // MockGiteaReviewFetcherAndRequesterAndUnrequester is a mock of GiteaReviewFetcherAndRequesterAndUnrequester interface.
type MockGiteaReviewFetcherAndRequesterAndUnrequester struct { type MockGiteaReviewFetcherAndRequesterAndUnrequester struct {
ctrl *gomock.Controller ctrl *gomock.Controller
@@ -1400,6 +1544,42 @@ func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterRequestReviewsCall) DoA
return c return c
} }
// ResetTimelineCache mocks base method.
func (m *MockGiteaReviewFetcherAndRequesterAndUnrequester) ResetTimelineCache(org, repo string, idx int64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "ResetTimelineCache", org, repo, idx)
}
// ResetTimelineCache indicates an expected call of ResetTimelineCache.
func (mr *MockGiteaReviewFetcherAndRequesterAndUnrequesterMockRecorder) ResetTimelineCache(org, repo, idx any) *MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetTimelineCache", reflect.TypeOf((*MockGiteaReviewFetcherAndRequesterAndUnrequester)(nil).ResetTimelineCache), org, repo, idx)
return &MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall{Call: call}
}
// MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall wrap *gomock.Call
type MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall) Return() *MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall {
c.Call = c.Call.Return()
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall) Do(f func(string, string, int64)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall) DoAndReturn(f func(string, string, int64)) *MockGiteaReviewFetcherAndRequesterAndUnrequesterResetTimelineCacheCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// UnrequestReview mocks base method. // UnrequestReview mocks base method.
func (m *MockGiteaReviewFetcherAndRequesterAndUnrequester) UnrequestReview(org, repo string, id int64, reviwers ...string) error { func (m *MockGiteaReviewFetcherAndRequesterAndUnrequester) UnrequestReview(org, repo string, id int64, reviwers ...string) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -1506,6 +1686,42 @@ func (c *MockGiteaUnreviewTimelineFetcherGetTimelineCall) DoAndReturn(f func(str
return c return c
} }
// ResetTimelineCache mocks base method.
func (m *MockGiteaUnreviewTimelineFetcher) ResetTimelineCache(org, repo string, idx int64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "ResetTimelineCache", org, repo, idx)
}
// ResetTimelineCache indicates an expected call of ResetTimelineCache.
func (mr *MockGiteaUnreviewTimelineFetcherMockRecorder) ResetTimelineCache(org, repo, idx any) *MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetTimelineCache", reflect.TypeOf((*MockGiteaUnreviewTimelineFetcher)(nil).ResetTimelineCache), org, repo, idx)
return &MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall{Call: call}
}
// MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall wrap *gomock.Call
type MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall) Return() *MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall {
c.Call = c.Call.Return()
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall) Do(f func(string, string, int64)) *MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall) DoAndReturn(f func(string, string, int64)) *MockGiteaUnreviewTimelineFetcherResetTimelineCacheCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// UnrequestReview mocks base method. // UnrequestReview mocks base method.
func (m *MockGiteaUnreviewTimelineFetcher) UnrequestReview(org, repo string, id int64, reviwers ...string) error { func (m *MockGiteaUnreviewTimelineFetcher) UnrequestReview(org, repo string, id int64, reviwers ...string) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@@ -3108,6 +3324,42 @@ func (c *MockGiteaRequestReviewsCall) DoAndReturn(f func(*models.PullRequest, ..
return c return c
} }
// ResetTimelineCache mocks base method.
func (m *MockGitea) ResetTimelineCache(org, repo string, idx int64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "ResetTimelineCache", org, repo, idx)
}
// ResetTimelineCache indicates an expected call of ResetTimelineCache.
func (mr *MockGiteaMockRecorder) ResetTimelineCache(org, repo, idx any) *MockGiteaResetTimelineCacheCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetTimelineCache", reflect.TypeOf((*MockGitea)(nil).ResetTimelineCache), org, repo, idx)
return &MockGiteaResetTimelineCacheCall{Call: call}
}
// MockGiteaResetTimelineCacheCall wrap *gomock.Call
type MockGiteaResetTimelineCacheCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGiteaResetTimelineCacheCall) Return() *MockGiteaResetTimelineCacheCall {
c.Call = c.Call.Return()
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGiteaResetTimelineCacheCall) Do(f func(string, string, int64)) *MockGiteaResetTimelineCacheCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGiteaResetTimelineCacheCall) DoAndReturn(f func(string, string, int64)) *MockGiteaResetTimelineCacheCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// SetCommitStatus mocks base method. // SetCommitStatus mocks base method.
func (m *MockGitea) SetCommitStatus(org, repo, hash string, status *models.CommitStatus) (*models.CommitStatus, error) { func (m *MockGitea) SetCommitStatus(org, repo, hash string, status *models.CommitStatus) (*models.CommitStatus, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()

View File

@@ -160,6 +160,8 @@ func FetchPRSet(user string, gitea GiteaPRTimelineReviewFetcher, org, repo strin
var pr *models.PullRequest var pr *models.PullRequest
var err error var err error
gitea.ResetTimelineCache(org, repo, num)
prjGitOrg, prjGitRepo, _ := config.GetPrjGit() prjGitOrg, prjGitRepo, _ := config.GetPrjGit()
if prjGitOrg == org && prjGitRepo == repo { if prjGitOrg == org && prjGitRepo == repo {
if pr, err = gitea.GetPullRequest(org, repo, num); err != nil { if pr, err = gitea.GetPullRequest(org, repo, num); err != nil {
@@ -184,6 +186,7 @@ func FetchPRSet(user string, gitea GiteaPRTimelineReviewFetcher, org, repo strin
for _, pr := range prs { for _, pr := range prs {
org, repo, idx := pr.PRComponents() org, repo, idx := pr.PRComponents()
gitea.ResetTimelineCache(org, repo, idx)
reviews, err := FetchGiteaReviews(gitea, org, repo, idx) reviews, err := FetchGiteaReviews(gitea, org, repo, idx)
if err != nil { if err != nil {
LogError("Error fetching reviews for", PRtoString(pr.PR), ":", err) LogError("Error fetching reviews for", PRtoString(pr.PR), ":", err)
@@ -316,7 +319,10 @@ func (rs *PRSet) FindMissingAndExtraReviewers(maintainers MaintainershipData, id
// only need project maintainer reviews if: // only need project maintainer reviews if:
// * not created by a bot and has other PRs, or // * not created by a bot and has other PRs, or
// * not created by maintainer // * not created by maintainer
noReviewPRCreators := prjMaintainers noReviewPRCreators := []string{}
if !rs.Config.ReviewRequired {
noReviewPRCreators = prjMaintainers
}
if len(rs.PRs) > 1 { if len(rs.PRs) > 1 {
noReviewPRCreators = append(noReviewPRCreators, rs.BotUser) noReviewPRCreators = append(noReviewPRCreators, rs.BotUser)
} }
@@ -339,7 +345,10 @@ func (rs *PRSet) FindMissingAndExtraReviewers(maintainers MaintainershipData, id
pkg := pr.PR.Base.Repo.Name pkg := pr.PR.Base.Repo.Name
pkgMaintainers := maintainers.ListPackageMaintainers(pkg, nil) pkgMaintainers := maintainers.ListPackageMaintainers(pkg, nil)
Maintainers := slices.Concat(prjMaintainers, pkgMaintainers) Maintainers := slices.Concat(prjMaintainers, pkgMaintainers)
noReviewPkgPRCreators := pkgMaintainers noReviewPkgPRCreators := []string{}
if !rs.Config.ReviewRequired {
noReviewPkgPRCreators = pkgMaintainers
}
LogDebug("packakge maintainers:", Maintainers) LogDebug("packakge maintainers:", Maintainers)

View File

@@ -833,7 +833,6 @@ func TestFindMissingAndExtraReviewers(t *testing.T) {
[]string{"autogits_obs_staging_bot", "user1"}, []string{"autogits_obs_staging_bot", "user1"},
}, },
}, },
{ {
name: "Add reviewer if also maintainer where review by maintainer is not needed", name: "Add reviewer if also maintainer where review by maintainer is not needed",
prset: &common.PRSet{ prset: &common.PRSet{
@@ -1095,8 +1094,67 @@ func TestFindMissingAndExtraReviewers(t *testing.T) {
expected_missing_reviewers: [][]string{{"pkgm2", "prj2"}}, expected_missing_reviewers: [][]string{{"pkgm2", "prj2"}},
expected_extra_reviewers: [][]string{{}, {"prj1"}}, 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,
},
},
maintainers: &common.MaintainershipMap{
Data: map[string][]string{
"pkg": {"pkgmaintainer", "pkgm1"},
},
},
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 { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
test.prset.HasAutoStaging = !test.noAutoStaging test.prset.HasAutoStaging = !test.noAutoStaging

View File

@@ -1,43 +1,54 @@
Group Review Bot 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 Main Tasks
ProjectGit. ----------
2. Assumes: workflow-pr needs to associate and define the PR set from 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>.”
which the groups.json is read (Base of the PrjGit PR)
Target Usage Target Usage
------------ ------------
Projects where policy reviews are required. 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: [ "ReviewGroups": [
{ {
"Name": "name of the group user", "Name": "name of the group user",
"Reviewers": ["members", "of", "group"], "Reviewers": ["members", "of", "group"],
"Silent": (true, false) -- if true, do not explicitly require review requests of group members "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 Requirements
------------ ------------
* Gitea token to: Gitea token with following permissions:
+ R/W PullRequest - R/W PullRequest
+ R/W Notification - R/W Notification
+ R User - R User
Env Variables Env Variables
------------- -------------

View File

@@ -18,20 +18,23 @@ import (
"src.opensuse.org/autogits/common/gitea-generated/models" "src.opensuse.org/autogits/common/gitea-generated/models"
) )
var configs common.AutogitConfigs type ReviewBot struct {
var acceptRx *regexp.Regexp configs common.AutogitConfigs
var rejectRx *regexp.Regexp acceptRx *regexp.Regexp
var groupName string rejectRx *regexp.Regexp
groupName string
func InitRegex(newGroupName string) { gitea common.Gitea
groupName = newGroupName
acceptRx = regexp.MustCompile("^:\\s*(LGTM|approved?)")
rejectRx = regexp.MustCompile("^:\\s*")
} }
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) line := strings.TrimSpace(reviewText)
groupTextName := "@" + groupName groupTextName := "@" + bot.groupName
glen := len(groupTextName) glen := len(groupTextName)
if len(line) < glen || line[0:glen] != groupTextName { if len(line) < glen || line[0:glen] != groupTextName {
return false, line return false, line
@@ -51,20 +54,20 @@ func ParseReviewLine(reviewText string) (bool, string) {
return false, line return false, line
} }
func ReviewAccepted(reviewText string) bool { func (bot *ReviewBot) ReviewAccepted(reviewText string) bool {
for _, line := range common.SplitStringNoEmpty(reviewText, "\n") { for _, line := range common.SplitStringNoEmpty(reviewText, "\n") {
if matched, reviewLine := ParseReviewLine(line); matched { if matched, reviewLine := bot.ParseReviewLine(line); matched {
return acceptRx.MatchString(reviewLine) return bot.acceptRx.MatchString(reviewLine)
} }
} }
return false return false
} }
func ReviewRejected(reviewText string) bool { func (bot *ReviewBot) ReviewRejected(reviewText string) bool {
for _, line := range common.SplitStringNoEmpty(reviewText, "\n") { for _, line := range common.SplitStringNoEmpty(reviewText, "\n") {
if matched, reviewLine := ParseReviewLine(line); matched { if matched, reviewLine := bot.ParseReviewLine(line); matched {
if rejectRx.MatchString(reviewLine) { if bot.rejectRx.MatchString(reviewLine) {
return !acceptRx.MatchString(reviewLine) return !bot.acceptRx.MatchString(reviewLine)
} }
} }
} }
@@ -114,10 +117,10 @@ var commentStrings = []string{
"change_time_estimate", "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 { for _, t := range timeline {
if t.Type == common.TimelineCommentType_Comment && t.User.UserName == user && t.Created == t.Updated { 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 return t
} }
} }
@@ -126,9 +129,9 @@ func FindAcceptableReviewInTimeline(user string, timeline []*models.TimelineComm
return nil return nil
} }
func FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.TimelineComment { func (bot *ReviewBot) FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.TimelineComment {
for _, t := range timeline { 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 return t
} }
} }
@@ -136,13 +139,13 @@ func FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.Tim
return nil return nil
} }
func UnrequestReviews(gitea common.Gitea, org, repo string, id int64, users []string) { func (bot *ReviewBot) UnrequestReviews(org, repo string, id int64, users []string) {
if err := gitea.UnrequestReview(org, repo, id, users...); err != nil { if err := bot.gitea.UnrequestReview(org, repo, id, users...); err != nil {
common.LogError("Can't remove reviewrs after a review:", err) 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() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
common.LogInfo("panic cought --- recovered") common.LogInfo("panic cought --- recovered")
@@ -169,14 +172,14 @@ func ProcessNotifications(notification *models.NotificationThread, gitea common.
id, _ := strconv.ParseInt(match[3], 10, 64) id, _ := strconv.ParseInt(match[3], 10, 64)
common.LogInfo("processing:", fmt.Sprintf("%s/%s!%d", org, repo, id)) common.LogInfo("processing:", fmt.Sprintf("%s/%s!%d", org, repo, id))
pr, err := gitea.GetPullRequest(org, repo, id) pr, err := bot.gitea.GetPullRequest(org, repo, id)
if err != nil { if err != nil {
common.LogError(" ** Cannot fetch PR associated with review:", subject.URL, "Error:", err) common.LogError(" ** Cannot fetch PR associated with review:", subject.URL, "Error:", err)
return return
} }
if err := ProcessPR(pr); err == nil && !common.IsDryRun { if err := bot.ProcessPR(pr); err == nil && !common.IsDryRun {
if err := gitea.SetNotificationRead(notification.ID); err != nil { if err := bot.gitea.SetNotificationRead(notification.ID); err != nil {
common.LogDebug(" Cannot set notification as read", err) common.LogDebug(" Cannot set notification as read", err)
} }
} else if err != nil && err != ReviewNotFinished { } else if err != nil && err != ReviewNotFinished {
@@ -186,24 +189,24 @@ func ProcessNotifications(notification *models.NotificationThread, gitea common.
var ReviewNotFinished = fmt.Errorf("Review is not finished") 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 org := pr.Base.Repo.Owner.UserName
repo := pr.Base.Repo.Name repo := pr.Base.Repo.Name
id := pr.Index id := pr.Index
found := false found := false
for _, reviewer := range pr.RequestedReviewers { for _, reviewer := range pr.RequestedReviewers {
if reviewer != nil && reviewer.UserName == groupName { if reviewer != nil && reviewer.UserName == bot.groupName {
found = true found = true
break break
} }
} }
if !found { if !found {
common.LogInfo(" review is not requested for", groupName) common.LogInfo(" review is not requested for", bot.groupName)
return nil return nil
} }
config := configs.GetPrjGitConfig(org, repo, pr.Base.Name) config := bot.configs.GetPrjGitConfig(org, repo, pr.Base.Name)
if config == nil { if config == nil {
return fmt.Errorf("Cannot find config for: %s", pr.URL) return fmt.Errorf("Cannot find config for: %s", pr.URL)
} }
@@ -213,17 +216,17 @@ func ProcessPR(pr *models.PullRequest) error {
return nil return nil
} }
reviews, err := gitea.GetPullRequestReviews(org, repo, id) reviews, err := bot.gitea.GetPullRequestReviews(org, repo, id)
if err != nil { if err != nil {
return fmt.Errorf("Failed to fetch reviews for: %v: %w", pr.URL, err) 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 { if err != nil {
return fmt.Errorf("Failed to fetch timeline to review. %w", err) 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 { if err != nil {
return fmt.Errorf("Failed to fetch review group. %w", err) return fmt.Errorf("Failed to fetch review group. %w", err)
} }
@@ -234,30 +237,30 @@ func ProcessPR(pr *models.PullRequest) error {
// pr.Head.Sha // pr.Head.Sha
for _, reviewer := range requestReviewers { for _, reviewer := range requestReviewers {
if review := FindAcceptableReviewInTimeline(reviewer, timeline, reviews); review != nil { if review := bot.FindAcceptableReviewInTimeline(reviewer, timeline, reviews); review != nil {
if ReviewAccepted(review.Body) { if bot.ReviewAccepted(review.Body) {
if !common.IsDryRun { if !common.IsDryRun {
text := reviewer + " approved a review on behalf of " + groupName text := reviewer + " approved a review on behalf of " + bot.groupName
if review := FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text { if review := bot.FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
_, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, text) _, err := bot.gitea.AddReviewComment(pr, common.ReviewStateApproved, text)
if err != nil { if err != nil {
common.LogError(" -> failed to write approval comment", err) 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(" -> approved by", reviewer)
common.LogInfo(" review at", review.Created) common.LogInfo(" review at", review.Created)
return nil return nil
} else if ReviewRejected(review.Body) { } else if bot.ReviewRejected(review.Body) {
if !common.IsDryRun { if !common.IsDryRun {
text := reviewer + " requested changes on behalf of " + groupName + ". See " + review.HTMLURL text := reviewer + " requested changes on behalf of " + bot.groupName + ". See " + review.HTMLURL
if review := FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text { if review := bot.FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text {
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, text) _, err := bot.gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, text)
if err != nil { if err != nil {
common.LogError(" -> failed to write rejecting comment", err) 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) common.LogInfo(" -> declined by", reviewer)
@@ -271,7 +274,7 @@ func ProcessPR(pr *models.PullRequest) error {
if !groupConfig.Silent && len(requestReviewers) > 0 { if !groupConfig.Silent && len(requestReviewers) > 0 {
common.LogDebug(" Requesting reviews for:", requestReviewers) common.LogDebug(" Requesting reviews for:", requestReviewers)
if !common.IsDryRun { if !common.IsDryRun {
if _, err := gitea.RequestReviews(pr, requestReviewers...); err != nil { if _, err := bot.gitea.RequestReviews(pr, requestReviewers...); err != nil {
common.LogDebug(" -> err:", err) common.LogDebug(" -> err:", err)
} }
} else { } else {
@@ -284,42 +287,40 @@ func ProcessPR(pr *models.PullRequest) error {
// add a helpful comment, if not yet added // add a helpful comment, if not yet added
found_help_comment := false found_help_comment := false
for _, t := range timeline { 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 found_help_comment = true
break break
} }
} }
if !found_help_comment && !common.IsDryRun { 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"+ "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 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: `@"+groupName+": decline` followed with lines justifying the decision.\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.") "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) { if slices.Contains(groupConfig.Reviewers, pr.User.UserName) {
helpComment = helpComment + "\n\n" + helpComment = helpComment + "\n\n" +
"Submitter is member of this review group, hence they are excluded from being one of the reviewers here" "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 return ReviewNotFinished
} }
func PeriodReviewCheck() { func (bot *ReviewBot) PeriodReviewCheck() {
notifications, err := gitea.GetNotifications(common.GiteaNotificationType_Pull, nil) notifications, err := bot.gitea.GetNotifications(common.GiteaNotificationType_Pull, nil)
if err != nil { if err != nil {
common.LogError(" Error fetching unread notifications: %w", err) common.LogError(" Error fetching unread notifications: %w", err)
return return
} }
for _, notification := range notifications { for _, notification := range notifications {
ProcessNotifications(notification, gitea) bot.ProcessNotifications(notification)
} }
} }
var gitea common.Gitea
func main() { func main() {
giteaUrl := flag.String("gitea-url", "https://src.opensuse.org", "Gitea instance used for reviews") 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") rabbitMqHost := flag.String("rabbit-url", "amqps://rabbit.opensuse.org", "RabbitMQ instance where Gitea webhook notifications are sent")
@@ -355,7 +356,7 @@ func main() {
flag.Usage() flag.Usage()
return return
} }
groupName = args[0] targetGroupName := args[0]
if *configFile == "" { if *configFile == "" {
common.LogError("Missing config file") common.LogError("Missing config file")
@@ -378,14 +379,14 @@ func main() {
return return
} }
gitea = common.AllocateGiteaTransport(*giteaUrl) giteaTransport := common.AllocateGiteaTransport(*giteaUrl)
configs, err = common.ResolveWorkflowConfigs(gitea, configData) configs, err := common.ResolveWorkflowConfigs(giteaTransport, configData)
if err != nil { if err != nil {
common.LogError("Cannot parse workflow configs:", err) common.LogError("Cannot parse workflow configs:", err)
return return
} }
reviewer, err := gitea.GetCurrentUser() reviewer, err := giteaTransport.GetCurrentUser()
if err != nil { if err != nil {
common.LogError("Cannot fetch review user:", err) common.LogError("Cannot fetch review user:", err)
return return
@@ -395,14 +396,18 @@ func main() {
*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(" ** username in Gitea:", reviewer.UserName)
common.LogInfo(" ** polling interval:", *interval, "min") common.LogInfo(" ** polling interval:", *interval, "min")
common.LogInfo(" ** connecting to RabbitMQ:", *rabbitMqHost) common.LogInfo(" ** connecting to RabbitMQ:", *rabbitMqHost)
if groupName != reviewer.UserName { if bot.groupName != reviewer.UserName {
common.LogError(" ***** Reviewer does not match group name. Aborting. *****") common.LogError(" ***** Reviewer does not match group name. Aborting. *****")
return return
} }
@@ -414,10 +419,13 @@ func main() {
} }
config_update := ConfigUpdatePush{ config_update := ConfigUpdatePush{
bot: bot,
config_modified: make(chan *common.AutogitConfig), config_modified: make(chan *common.AutogitConfig),
} }
process_issue_pr := IssueCommentProcessor{} process_issue_pr := IssueCommentProcessor{
bot: bot,
}
configUpdates := &common.RabbitMQGiteaEventsProcessor{ configUpdates := &common.RabbitMQGiteaEventsProcessor{
Orgs: []string{}, Orgs: []string{},
@@ -427,7 +435,7 @@ func main() {
}, },
} }
configUpdates.Connection().RabbitURL = u configUpdates.Connection().RabbitURL = u
for _, c := range configs { for _, c := range bot.configs {
if org, _, _ := c.GetPrjGit(); !slices.Contains(configUpdates.Orgs, org) { if org, _, _ := c.GetPrjGit(); !slices.Contains(configUpdates.Orgs, org) {
configUpdates.Orgs = append(configUpdates.Orgs, org) configUpdates.Orgs = append(configUpdates.Orgs, org)
} }
@@ -440,17 +448,17 @@ func main() {
select { select {
case configTouched, ok := <-config_update.config_modified: case configTouched, ok := <-config_update.config_modified:
if ok { if ok {
for idx, c := range configs { for idx, c := range bot.configs {
if c == configTouched { if c == configTouched {
org, repo, branch := c.GetPrjGit() org, repo, branch := c.GetPrjGit()
prj := fmt.Sprintf("%s/%s#%s", org, repo, branch) prj := fmt.Sprintf("%s/%s#%s", org, repo, branch)
common.LogInfo("Detected config update for", prj) 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 { if err != nil {
common.LogError("Failed parsing Project config for", prj, err) common.LogError("Failed parsing Project config for", prj, err)
} else { } else {
configs[idx] = new_config bot.configs[idx] = new_config
} }
} }
} }
@@ -460,7 +468,7 @@ func main() {
} }
} }
PeriodReviewCheck() bot.PeriodReviewCheck()
time.Sleep(time.Duration(*interval * int64(time.Minute))) time.Sleep(time.Duration(*interval * int64(time.Minute)))
} }
} }

View File

@@ -1,6 +1,359 @@
package main 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) { func TestReviewApprovalCheck(t *testing.T) {
tests := []struct { tests := []struct {
@@ -60,16 +413,78 @@ func TestReviewApprovalCheck(t *testing.T) {
InString: "@group2: disapprove", InString: "@group2: disapprove",
Rejected: true, 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 { for _, test := range tests {
t.Run(test.Name, func(t *testing.T) { 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) 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) t.Error("ReviewRejected() returned", r, "expecting", test.Rejected)
} }
}) })

View File

@@ -7,7 +7,9 @@ import (
"src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common"
) )
type IssueCommentProcessor struct{} type IssueCommentProcessor struct {
bot *ReviewBot
}
func (s *IssueCommentProcessor) ProcessFunc(req *common.Request) error { func (s *IssueCommentProcessor) ProcessFunc(req *common.Request) error {
if req.Type != common.RequestType_IssueComment { if req.Type != common.RequestType_IssueComment {
@@ -19,14 +21,15 @@ func (s *IssueCommentProcessor) ProcessFunc(req *common.Request) error {
repo := data.Repository.Name repo := data.Repository.Name
index := int64(data.Issue.Number) index := int64(data.Issue.Number)
pr, err := gitea.GetPullRequest(org, repo, index) pr, err := s.bot.gitea.GetPullRequest(org, repo, index)
if err != nil { if err != nil {
return fmt.Errorf("Failed to fetch PullRequest from event: %s/%s!%d Error: %w", org, repo, index, err) 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 { type ConfigUpdatePush struct {
bot *ReviewBot
config_modified chan *common.AutogitConfig config_modified chan *common.AutogitConfig
} }
@@ -46,7 +49,7 @@ func (s *ConfigUpdatePush) ProcessFunc(req *common.Request) error {
} }
branch := data.Ref[len(branch_ref):] branch := data.Ref[len(branch_ref):]
c := configs.GetPrjGitConfig(org, repo, branch) c := s.bot.configs.GetPrjGitConfig(org, repo, branch)
if c == nil { if c == nil {
return nil return nil
} }
@@ -64,7 +67,7 @@ func (s *ConfigUpdatePush) ProcessFunc(req *common.Request) error {
} }
if modified_config { if modified_config {
for _, config := range configs { for _, config := range s.bot.configs {
if o, r, _ := config.GetPrjGit(); o == org && r == repo { if o, r, _ := config.GetPrjGit(); o == org && r == repo {
s.config_modified <- config s.config_modified <- config
} }

203
group-review/rabbit_test.go Normal file
View File

@@ -0,0 +1,203 @@
package main
import (
"fmt"
"testing"
"go.uber.org/mock/gomock"
"src.opensuse.org/autogits/common"
"src.opensuse.org/autogits/common/gitea-generated/models"
mock_common "src.opensuse.org/autogits/common/mock"
)
func TestIssueCommentProcessor(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockGitea := mock_common.NewMockGitea(ctrl)
groupName := "testgroup"
bot := &ReviewBot{
gitea: mockGitea,
groupName: groupName,
}
bot.InitRegex(groupName)
processor := &IssueCommentProcessor{bot: bot}
org := "myorg"
repo := "myrepo"
index := 123
event := &common.IssueCommentWebhookEvent{
Repository: &common.Repository{
Name: repo,
Owner: &common.Organization{
Username: org,
},
},
Issue: &common.IssueDetail{
Number: index,
},
}
req := &common.Request{
Type: common.RequestType_IssueComment,
Data: event,
}
t.Run("Successful Processing", func(t *testing.T) {
pr := &models.PullRequest{
Index: int64(index),
Base: &models.PRBranchInfo{
Name: "main",
Repo: &models.Repository{
Name: repo,
Owner: &models.User{UserName: org},
},
},
Head: &models.PRBranchInfo{
Sha: "headsha",
Repo: &models.Repository{
Name: repo,
Owner: &models.User{UserName: org},
},
},
User: &models.User{UserName: "submitter"},
RequestedReviewers: []*models.User{{UserName: groupName}},
}
mockGitea.EXPECT().GetPullRequest(org, repo, int64(index)).Return(pr, nil)
prjConfig := &common.AutogitConfig{
GitProjectName: org + "/" + repo + "#main",
ReviewGroups: []*common.ReviewGroup{{Name: groupName, Reviewers: []string{"r1"}}},
}
bot.configs = common.AutogitConfigs{prjConfig}
mockGitea.EXPECT().GetPullRequestReviews(org, repo, int64(index)).Return(nil, nil)
mockGitea.EXPECT().GetTimeline(org, repo, int64(index)).Return(nil, nil)
mockGitea.EXPECT().RequestReviews(pr, "r1").Return(nil, nil)
mockGitea.EXPECT().AddComment(pr, gomock.Any()).Return(nil)
err := processor.ProcessFunc(req)
if err != ReviewNotFinished {
t.Errorf("Expected ReviewNotFinished, got %v", err)
}
})
t.Run("Gitea error in GetPullRequest", func(t *testing.T) {
mockGitea.EXPECT().GetPullRequest(org, repo, int64(index)).Return(nil, fmt.Errorf("gitea error"))
err := processor.ProcessFunc(req)
if err == nil {
t.Error("Expected error, got nil")
}
})
t.Run("Wrong Request Type", func(t *testing.T) {
wrongReq := &common.Request{Type: common.RequestType_Push}
err := processor.ProcessFunc(wrongReq)
if err == nil {
t.Error("Expected error for wrong request type, got nil")
}
})
}
func TestConfigUpdatePush(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
groupName := "testgroup"
bot := &ReviewBot{
groupName: groupName,
}
bot.InitRegex(groupName)
configChan := make(chan *common.AutogitConfig, 1)
processor := &ConfigUpdatePush{
bot: bot,
config_modified: configChan,
}
org := "myorg"
repo := "myrepo"
branch := "main"
prjConfig := &common.AutogitConfig{
GitProjectName: org + "/" + repo + "#" + branch,
Organization: org,
Branch: branch,
}
bot.configs = common.AutogitConfigs{prjConfig}
event := &common.PushWebhookEvent{
Ref: "refs/heads/" + branch,
Repository: &common.Repository{
Name: repo,
Owner: &common.Organization{
Username: org,
},
},
Commits: []common.Commit{
{
Modified: []string{common.ProjectConfigFile},
},
},
}
req := &common.Request{
Type: common.RequestType_Push,
Data: event,
}
t.Run("Config Modified", func(t *testing.T) {
err := processor.ProcessFunc(req)
if err != nil {
t.Errorf("Expected nil error, got %v", err)
}
select {
case modified := <-configChan:
if modified != prjConfig {
t.Errorf("Expected modified config to be %v, got %v", prjConfig, modified)
}
default:
t.Error("Expected config modification signal, but none received")
}
})
t.Run("No Config Modified", func(t *testing.T) {
noConfigEvent := *event
noConfigEvent.Commits = []common.Commit{{Modified: []string{"README.md"}}}
noConfigReq := &common.Request{Type: common.RequestType_Push, Data: &noConfigEvent}
err := processor.ProcessFunc(noConfigReq)
if err != nil {
t.Errorf("Expected nil error, got %v", err)
}
select {
case <-configChan:
t.Error("Did not expect config modification signal")
default:
// Success
}
})
t.Run("Wrong Branch Ref", func(t *testing.T) {
wrongBranchEvent := *event
wrongBranchEvent.Ref = "refs/tags/v1.0"
wrongBranchReq := &common.Request{Type: common.RequestType_Push, Data: &wrongBranchEvent}
err := processor.ProcessFunc(wrongBranchReq)
if err == nil {
t.Error("Expected error for wrong branch ref, got nil")
}
})
t.Run("Config Not Found", func(t *testing.T) {
bot.configs = common.AutogitConfigs{}
err := processor.ProcessFunc(req)
if err != nil {
t.Errorf("Expected nil error even if config not found, got %v", err)
}
})
}

View File

@@ -4,11 +4,15 @@ OBS Staging Bot
Build a PR against a ProjectGit, if review is requested. Build a PR against a ProjectGit, if review is requested.
Areas of Responsibility Main Tasks
----------------------- ----------
* Monitors Notification API in Gitea for review requests * A build in OBS is initiated when a review for this bot is requested.
* Reviews Package build results in OBS for all changed packages in ProjectGit PR * 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 Target Usage
@@ -21,22 +25,48 @@ Configuration File
------------------ ------------------
Bot reads `staging.config` from the project git or the PR to the project git. 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", "ObsProject": "SUSE:SLFO:1.2",
"StagingProject": "home:foo:project:staging", "StagingProject": "SUSE:SLFO:1.2:PullRequest",
"QA": [ "QA": [
{ {
"Name": "ProjectBuild", "Name": "SLES",
"Origin": "home:foo:product:images" "Origin": "SUSE:SLFO:Products:SLES:16.0",
} "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 | Field name | Details | Mandatory | Type | Allowed Values | Default |
* 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. | *ObsProject* | Product OBS project. Builds in this project will be used to compare to builds based on sources from the PR. | yes | string | `[a-zA-Z0-9-_:]+` | |
| *StagingProject* | Used both as base project and prefix for all OBS staging projects. Upon being added as a reviewer to a PrjGit PR, this bot automatically generates an OBS project named *StagingProject:<PR_Number>*. It must be a sub-project of the *ObsProject*. | yes | string | `[a-zA-Z0-9-_:]+` | |
| *QA* | Crucial for generating a product build (such as an ISO or FTP tree) that incorporates the packages. | no | array of objects | | |
| *QA > Name* | Suffix for the QA OBS staging project. The project is named *StagingProject:<PR_Number>:Name*. | no | string | | |
| *QA > Origin* | OBS reference project | no | string | | |
| *QA > BuildDisableRepos* | The names of OBS repositories to build-disable, if any. | no | array of strings | | [] |
Details
-------
* **OBS staging projects are deleted** when the relative PrjGit PR is closed or merged.
* **PrjGit PR - staging project**
* The OBS staging project utilizes an **scmsync** tag, configured with the `onlybuild` flag, to exclusively build packages associated with this specific PrjGit PR.
* The **build config** is inherited from the PrjGit PR config file (even if unchanged).
* The **project meta** creates a standard repository following the StagingProject as a project path.
* The base *StagingProject* has the macro **FromScratch:** set in its config, which prevents inheriting the configuration from the included project paths.
* The bot copies the project maintainers from *StagingProject* to the specific staging project (*StagingProject:<PR_Number>*).
* The bot reports “Build successful” only if the build is successful for all repositories and all architectures.
* **PrjGit PR - QA staging project**
* The QA staging project is meant for building the product; the relative build config is inherited from the `QA > Origin` project.
* In this case, the **scmsync** tag is inherited from the `QA > Origin` project.
* It is desirable in some cases to avoid building some specific build service repositories when not needed. In this case, `QA > BuildDisableRepos` can be specified.
These repositories would be disabled in the project meta when generating the QA project.

View File

@@ -109,6 +109,11 @@ const (
BuildStatusSummaryUnknown = 4 BuildStatusSummaryUnknown = 4
) )
type DisableFlag struct {
XMLName string `xml:"disable"`
Name string `xml:"repository,attr"`
}
func ProcessBuildStatus(project, refProject *common.BuildResultList) BuildStatusSummary { func ProcessBuildStatus(project, refProject *common.BuildResultList) BuildStatusSummary {
if _, finished := refProject.BuildResultSummary(); !finished { if _, finished := refProject.BuildResultSummary(); !finished {
common.LogDebug("refProject not finished building??") common.LogDebug("refProject not finished building??")
@@ -377,7 +382,7 @@ func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullReque
// stagingProject:$buildProject // stagingProject:$buildProject
// ^- stagingProject:$buildProject:$subProjectName (based on templateProject) // ^- 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("Setup QA sub projects")
templateMeta, err := ObsClient.GetProjectMeta(templateProject) templateMeta, err := ObsClient.GetProjectMeta(templateProject)
if err != nil { if err != nil {
@@ -407,7 +412,21 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git
repository.Fragment = branch.SHA repository.Fragment = branch.SHA
templateMeta.ScmSync = repository.String() templateMeta.ScmSync = repository.String()
common.LogDebug("Setting scmsync url to ", templateMeta.ScmSync) common.LogDebug("Setting scmsync url to ", templateMeta.ScmSync)
} }
// Build-disable repositories if asked
if len(buildDisableRepos) > 0 {
toDisable := make([]DisableFlag, len(buildDisableRepos))
for idx, repositoryName := range buildDisableRepos {
toDisable[idx] = DisableFlag{Name: repositoryName}
}
output, err := xml.Marshal(toDisable)
if err != nil {
common.LogError("error while marshalling, skipping BuildDisableRepos: ", err)
} else {
templateMeta.BuildFlags.Contents += string(output)
}
}
// Cleanup ReleaseTarget and modify affected path entries // Cleanup ReleaseTarget and modify affected path entries
for idx, r := range templateMeta.Repositories { for idx, r := range templateMeta.Repositories {
templateMeta.Repositories[idx].ReleaseTargets = nil templateMeta.Repositories[idx].ReleaseTargets = nil
@@ -922,7 +941,8 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e
CreateQASubProject(stagingConfig, git, gitea, pr, CreateQASubProject(stagingConfig, git, gitea, pr,
stagingProject, stagingProject,
setup.Origin, setup.Origin,
setup.Name) setup.Name,
setup.BuildDisableRepos)
msg = msg + ObsWebHost + "/project/show/" + msg = msg + ObsWebHost + "/project/show/" +
stagingProject + ":" + setup.Name + "\n" stagingProject + ":" + setup.Name + "\n"
} }

View File

@@ -1,98 +0,0 @@
package main
import (
"flag"
"fmt"
"os"
"slices"
"src.opensuse.org/autogits/common"
)
var (
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")
)
const maintainershipFile = "_maintainership.json"
func WriteNewMaintainershipFile(m *common.MaintainershipMap, filename string) {
f, err := os.Create(filename + ".new")
common.PanicOnError(err)
common.PanicOnError(m.WriteMaintainershipFile(f))
common.PanicOnError(f.Close())
common.PanicOnError(os.Rename(filename+".new", filename))
}
func run() error {
flag.Parse()
filename := 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

@@ -1,243 +0,0 @@
package main
import (
"encoding/json"
"flag"
"os"
"os/exec"
"reflect"
"strings"
"testing"
)
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{},
expectedError: "no such file or directory",
},
{
name: "invalid json",
inData: `{"pkg1": ["user1"`,
params: []string{},
expectedError: "Failed to parse JSON",
},
{
name: "add and remove",
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,
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 := 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)
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")
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(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(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,29 +1,41 @@
Direct Workflow bot 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 Areas of responsibility
----------------------- -----------------------
1. Keep ProjectGit in sync with packages in the organization 1. Keep ProjectGit in sync with packages in the organization
* on pushes to package, updates the submodule commit id * **On pushes to package**: updates the submodule commit ID to the default branch HEAD (as configured in Gitea).
to the default branch HEAD (as configured in Gitea) * **On repository adds**: creates a new submodule (if non-empty).
* on repository adds, creates a new submodule (if non empty) * **On repository removal**: removes the submodule.
* 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.
Configuration Configuration
------------- -------------
Uses `workflow.config` for configuration. Parameters Uses `workflow.config` for configuration.
* _Workflows_: ["direct"] -- direct entry enables direct workflow. **Mandatory** | Field name | Details | Mandatory | Type | Allowed Values | Default |
* _Organization_: organization that holds all the packages. **Mandatory** | ----- | ----- | ----- | ----- | ----- | ----- |
* _Branch_: branch updated in repo's, or blank for default package branch | *Workflows* | Type of workflow | yes | string | “direct” | |
* _GitProjectName_: package in above org, or `org/package#branch` for PrjGit. By default assumes `_ObsPrj` with default branch and in the `Organization` | *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. 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.
Running
Environment Variables
------- -------
* `GITEA_TOKEN` (required) * `GITEA_TOKEN` (required)
@@ -37,8 +49,3 @@ Running
* `AUTOGITS_REPO_PATH` - default is temporary directory * `AUTOGITS_REPO_PATH` - default is temporary directory
* `AUTOGITS_IDENTITY_FILE` - in case where we need explicit identify path for ssh specified * `AUTOGITS_IDENTITY_FILE` - in case where we need explicit identify path for ssh specified
Target Usage
------------
Devel project, where direct pushes to package git are possible

View File

@@ -1,55 +1,65 @@
Workflow-PR bot Workflow-PR bot
=============== ===============
Keeps ProjectGit PR in-sync with a PackageGit PR Keeps ProjectGit PRs in-sync with the relative PackageGit PRs.
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
Target Usage 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 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.
* _Labels_: (string, string) Labels for PRs. 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. * Filename: `workflow.config`
example: * 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 Reviewers
--------- ---------
@@ -58,21 +68,30 @@ Reviews is a list of accounts that need to review package and/or project. They h
[~][*|-|+]username [~][*|-|+]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: Other prefixes indicate project or package association of the reviewer:
* `*` indicates project *and* package * `*` indicates project *and* package
* `-` indicates project-only reviewer * `-` indicates project-only reviewer
* `+` indicates package-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 Package Deletion Requests
* foo -> package reviews -------------------------
* bar -> project reviews (NOT YET IMPLEMENTED)
* moo -> package and project reviews, but ignored
* **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 Labels
@@ -83,26 +102,38 @@ The following labels are used, when defined in Repo/Org.
| Label Config Entry | Default label | Description | Label Config Entry | Default label | Description
|--------------------|----------------|---------------------------------------- |--------------------|----------------|----------------------------------------
| StagingAuto | staging/Auto | Assigned to Project Git PRs when first staged | StagingAuto | staging/Auto | Assigned to Project Git PRs when first staged
| ReviewPending | review/Pending | Assigned to PR when reviews are still pending | ReviewPending | review/Pending | Assigned to Project Git PR when package reviews are still pending
| ReviewDone | review/Done | Assigned to PR when reviews are complete on this particular PR | ReviewDone | review/Done | Assigned to Project Git PR when reviews are complete on all package PRs
Maintainership Maintainership
-------------- --------------
Maintainership information is defined per project. For reviews, package maintainers are coalesced Filename: \_maintainership.json
with project maintainers. A review by any of the maintainers is acceptable. 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 |
{ 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.
"package1": [ "reviewer", "reviewer2"],
"package2": [],
// "project" maintainer If the submitter is a maintainer it will not get a review requested.
"": ["reviewer3", "reviewer4"]
}
Example:
```
{
"package1": [ "reviewer", "reviewer2"],
"package2": [],
// "project" maintainer
"": ["reviewer3", "reviewer4"]
}
```
Permissions Permissions
----------- -----------
@@ -122,3 +153,11 @@ the `workflow.config`.
NOTE: Project Maintainers have these permissions automatically. NOTE: Project Maintainers have these permissions automatically.
Server configuration
--------------------------
**Configuration file:**
| Field | Type | Notes |
| ----- | ----- | ----- |
| root | Array of string | Format **org/repo\#branch** |

View File

@@ -498,7 +498,7 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
// make sure that prjgit is consistent and only submodules that are to be *updated* // 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 // reset anything that changed that is not part of the prset
// package removals/additions are *not* counted here // package removals/additions are *not* counted here
org, repo, branch := config.GetPrjGit()
// TODO: this is broken... // TODO: this is broken...
if pr, err := prset.GetPrjGitPR(); err == nil && false { if pr, err := prset.GetPrjGitPR(); err == nil && false {
common.LogDebug("Submodule parse begin") common.LogDebug("Submodule parse begin")
@@ -547,7 +547,7 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
} else { } else {
common.LogInfo("* No prjgit") common.LogInfo("* No prjgit")
} }
maintainers, err := common.FetchProjectMaintainershipData(Gitea, org, repo, branch) maintainers, err := common.FetchProjectMaintainershipData(Gitea, config)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -111,6 +111,7 @@ func TestOpenPR(t *testing.T) {
t.Run("PR git opened request against PrjGit == no action", func(t *testing.T) { t.Run("PR git opened request against PrjGit == no action", func(t *testing.T) {
ctl := gomock.NewController(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 Gitea = gitea
git.GitPath = t.TempDir() git.GitPath = t.TempDir()
@@ -157,6 +158,7 @@ func TestOpenPR(t *testing.T) {
t.Run("Open PrjGit PR", func(t *testing.T) { t.Run("Open PrjGit PR", func(t *testing.T) {
ctl := gomock.NewController(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 Gitea = gitea
@@ -210,6 +212,7 @@ func TestOpenPR(t *testing.T) {
t.Run("Cannot create prjgit repository", func(t *testing.T) { t.Run("Cannot create prjgit repository", func(t *testing.T) {
ctl := gomock.NewController(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 Gitea = gitea
@@ -258,6 +261,7 @@ func TestOpenPR(t *testing.T) {
t.Run("Cannot create PR", func(t *testing.T) { t.Run("Cannot create PR", func(t *testing.T) {
ctl := gomock.NewController(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 Gitea = gitea
@@ -309,6 +313,7 @@ func TestOpenPR(t *testing.T) {
t.Run("Open PrjGit PR", func(t *testing.T) { t.Run("Open PrjGit PR", func(t *testing.T) {
ctl := gomock.NewController(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 Gitea = gitea

View File

@@ -76,6 +76,7 @@ func TestSyncPR(t *testing.T) {
ctl := gomock.NewController(t) ctl := gomock.NewController(t)
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
// Common expectations for FetchPRSet and downstream checks // Common expectations for FetchPRSet and downstream checks
@@ -110,6 +111,7 @@ func TestSyncPR(t *testing.T) {
ctl := gomock.NewController(t) ctl := gomock.NewController(t)
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes() gitea.EXPECT().GetPullRequestReviews(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.PullReview{}, nil).AnyTimes()
@@ -131,6 +133,7 @@ func TestSyncPR(t *testing.T) {
ctl := gomock.NewController(t) ctl := gomock.NewController(t)
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
setupGitForTests(t, git) setupGitForTests(t, git)

View File

@@ -304,6 +304,7 @@ func TestUpdatePrjGitPR(t *testing.T) {
mockGit := mock_common.NewMockGit(ctl) mockGit := mock_common.NewMockGit(ctl)
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
CurrentUser = &models.User{UserName: "bot"} CurrentUser = &models.User{UserName: "bot"}
@@ -521,6 +522,7 @@ func TestCreatePRjGitPR_Integration(t *testing.T) {
mockGit := mock_common.NewMockGit(ctl) mockGit := mock_common.NewMockGit(ctl)
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
config := &common.AutogitConfig{ config := &common.AutogitConfig{
@@ -640,6 +642,7 @@ func TestPRProcessor_Process_EdgeCases(t *testing.T) {
mockGit := mock_common.NewMockGit(ctl) mockGit := mock_common.NewMockGit(ctl)
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
CurrentUser = &models.User{UserName: "bot"} CurrentUser = &models.User{UserName: "bot"}
@@ -762,6 +765,7 @@ func TestVerifyRepositoryConfiguration(t *testing.T) {
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
repo := &models.Repository{ repo := &models.Repository{
@@ -807,6 +811,7 @@ func TestProcessFunc(t *testing.T) {
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
mockGit := mock_common.NewMockGit(ctl) mockGit := mock_common.NewMockGit(ctl)
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl) mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)

View File

@@ -17,6 +17,7 @@ func TestPrjGitSubmoduleCheck(t *testing.T) {
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit := mock_common.NewMockGit(ctl) mockGit := mock_common.NewMockGit(ctl)
Gitea = gitea Gitea = gitea
@@ -97,6 +98,7 @@ func TestPrjGitSubmoduleCheck_Failures(t *testing.T) {
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
mockGit := mock_common.NewMockGit(ctl) mockGit := mock_common.NewMockGit(ctl)
Gitea = gitea Gitea = gitea
@@ -154,6 +156,7 @@ func TestDefaultStateChecker_ProcessPR(t *testing.T) {
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
mockGit := mock_common.NewMockGit(ctl) mockGit := mock_common.NewMockGit(ctl)
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl) mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
@@ -201,6 +204,7 @@ func TestDefaultStateChecker_VerifyProjectState(t *testing.T) {
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
mockGit := mock_common.NewMockGit(ctl) mockGit := mock_common.NewMockGit(ctl)
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl) mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)
@@ -264,6 +268,7 @@ func TestDefaultStateChecker_CheckRepos(t *testing.T) {
defer ctl.Finish() defer ctl.Finish()
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
Gitea = gitea Gitea = gitea
mockGit := mock_common.NewMockGit(ctl) mockGit := mock_common.NewMockGit(ctl)
mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl) mockGitGen := mock_common.NewMockGitHandlerGenerator(ctl)

View File

@@ -65,6 +65,7 @@ func TestRepoCheck(t *testing.T) {
ctl := gomock.NewController(t) ctl := gomock.NewController(t)
state := NewMockStateChecker(ctl) state := NewMockStateChecker(ctl)
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
config1 := &common.AutogitConfig{ config1 := &common.AutogitConfig{
GitProjectName: "git_repo1", GitProjectName: "git_repo1",
@@ -101,6 +102,7 @@ func TestRepoCheck(t *testing.T) {
ctl := gomock.NewController(t) ctl := gomock.NewController(t)
state := NewMockStateChecker(ctl) state := NewMockStateChecker(ctl)
gitea := mock_common.NewMockGitea(ctl) gitea := mock_common.NewMockGitea(ctl)
gitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
config1 := &common.AutogitConfig{ config1 := &common.AutogitConfig{
GitProjectName: "git_repo1", GitProjectName: "git_repo1",
@@ -145,6 +147,7 @@ func TestVerifyProjectState(t *testing.T) {
t.Run("Project state with no PRs", func(t *testing.T) { t.Run("Project state with no PRs", func(t *testing.T) {
ctl := gomock.NewController(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()
git := &common.GitHandlerImpl{ git := &common.GitHandlerImpl{
GitCommiter: "TestCommiter", GitCommiter: "TestCommiter",
@@ -190,6 +193,7 @@ func TestVerifyProjectState(t *testing.T) {
t.Run("Project state with 1 PRs that doesn't trigger updates", func(t *testing.T) { t.Run("Project state with 1 PRs that doesn't trigger updates", func(t *testing.T) {
ctl := gomock.NewController(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()
git := &common.GitHandlerImpl{ git := &common.GitHandlerImpl{
GitCommiter: "TestCommiter", GitCommiter: "TestCommiter",