Compare commits

..

9 Commits

Author SHA256 Message Date
8fa732e675 common: fix three bugs in the timeline cache
Some checks failed
go-generate-check / go-generate-check (pull_request) Successful in 16s
Integration tests / t (pull_request) Failing after 11m8s
1. Double-check locking (TOCTOU)
   GetTimeline dropped the read lock, then acquired the write lock to
   fetch fresh data, but never re-checked the cache after the write lock
   was acquired.  A second goroutine could pass the stale-check under the
   read lock at the same time, resulting in both goroutines fetching the
   full timeline independently and overwriting each other's result.  Add
   a re-check immediately after acquiring the write lock so only the
   first writer fetches.

2. LastCachedTime used the wrong item
   The incremental fetch used data[0].Updated as the high-water mark for
   the Since filter, but data is sorted in descending Created order so
   data[0] is the most-recently-created entry, whose Updated timestamp
   may not be the latest across all cached items.  Switch to a linear
   scan over all items to find the true maximum Updated value, matching
   the fix in origin/fix/timeline-cache-race.

3. Cache not invalidated after RequestReviews / UnrequestReview
   After the bot called RequestReviews or UnrequestReview, the timeline
   cache for that PR was left hot for up to 5 seconds.  Any subsequent
   GetTimeline call within that window returned stale data that did not
   include the just-created review_requested / review_request_removed
   entry, causing FindMissingAndExtraReviewers to make decisions based on
   outdated reviewer state.  Call ResetTimelineCache on success in both
   methods so the next read always sees the mutation.
2026-03-10 21:50:29 +01:00
572e33111b workflow-pr: fix race conditions in event processing
All checks were successful
go-generate-check / go-generate-check (pull_request) Successful in 12s
Integration tests / t (pull_request) Successful in 11m31s
Three targeted fixes for the reviewer add/remove race condition:

1. Per-PR serialization lock (prLocks sync.Map)
   Both the RabbitMQ event loop and the ConsistencyCheckProcess goroutine
   converge on ProcesPullRequest with no mutual exclusion, allowing them
   to race on AssignReviewers/UnrequestReview for the same PR.  A
   buffered-channel-per-PR-key mutex serialises all processing for a
   given PR regardless of which goroutine triggers it.

2. Self-triggered pull_request_sync filter
   The bot's own pushes to prjgit branches cause Gitea to fire a
   pull_request_sync event back, which would re-run AssignReviewers on
   already-settled reviewer state, producing spurious add/remove cycles.
   Events where Sender.Username matches the bot's own account are now
   dropped early in ProcessFunc.

3. Requeue retry moved to background goroutine
   The updatePrjGitError_requeue path was sleeping 5 s inside the event
   loop goroutine (blocking all further event processing) then calling
   ProcessFunc recursively.  It now schedules the retry in a goroutine;
   the per-PR lock prevents the retry from racing with events that arrive
   during the sleep window.  The recursive counter is removed as it only
   guarded this path.
2026-03-10 16:24:05 +01:00
b04755c667 Merge branch 'main' into refactoring-make
Some checks failed
Integration tests / t (pull_request) Failing after 11m36s
Integration tests / t (push) Failing after 11m46s
2026-03-10 12:30:20 +01:00
Andrii Nikitin
e1ed2e78e0 t: create the repos using sha256 format
All checks were successful
Integration tests / t (pull_request) Successful in 11m27s
Integration tests / t (push) Successful in 11m18s
- add "object_format_name": "sha256" to api in create_repo()
- update add_submodules() and diff to use sha256 style
2026-03-09 13:27:34 +01:00
Andrii Nikitin
e494d545e7 t: tweak merge tests 002 and 003 to properly address expected behavior
All checks were successful
Integration tests / t (pull_request) Successful in 13m59s
Integration tests / t (push) Successful in 11m19s
2026-03-09 11:08:25 +01:00
Andrii Nikitin
e1ce27250b t: refactor dedicated container for pytest
- remove test-obs service
- mock OBS calls using pytest-httpserver
- dedicated container for pytest to make sure it is in the same network as the services
- remove restart of obs-staging-bot and use new poll interval for it
- rework Makefile targets
2026-03-09 11:08:14 +01:00
Andrii Nikitin
f87b3345fc t: improve startup orchestration and dependencies
- Add 'zz-ready-to-start' branch as a final initialization marker in fixtures.
- Update workflow-pr entrypoint to wait for the final initialization branch.
- Implement health check for workflow-pr in podman-compose.
- Refine service dependencies to ensure Gitea and core services are ready before bots start.
2026-03-09 10:13:26 +01:00
Andrii Nikitin
5440476d10 staging: Add config for poll interval
Some checks failed
go-generate-check / go-generate-check (pull_request) Successful in 13s
Integration tests / t (pull_request) Has been cancelled
go-generate-check / go-generate-check (push) Successful in 22s
Integration tests / t (push) Has been cancelled
Needed for testing
2026-03-09 09:29:48 +01:00
1fc0be5f60 make: refactor build target into per-module dependencies
Some checks failed
Integration tests / t (pull_request) Has been cancelled
- Replace shell loop in build with explicit module targets
- Add .PHONY for build and module targets
- Keep go build -C <module> -buildmode=pie behavior unchanged
- Enable parallel builds via make -jN build
2026-03-06 11:33:32 +01:00
19 changed files with 142 additions and 98 deletions

View File

@@ -34,7 +34,9 @@ jobs:
run: make build
working-directory: ./autogits
- name: Prepare images
run: make build
run: |
make build
podman rmi $(podman images -f "dangling=true" -q)
working-directory: ./autogits/integration
- name: Make sure the pod is down
run: make down
@@ -43,12 +45,16 @@ jobs:
run: |
make up
make wait_healthy
podman ps
sleep 5
working-directory: ./autogits/integration
- name: Run tests
run: make pytest
working-directory: ./autogits/integration
- name: Make sure the pod is down
if: always()
run: make down
run: |
podman ps
make down
working-directory: ./autogits/integration

View File

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

View File

@@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"io"
"log"
"os"
"slices"
"strings"
@@ -204,21 +205,16 @@ func ReadWorkflowConfig(gitea GiteaFileContentAndRepoFetcher, git_project string
func ResolveWorkflowConfigs(gitea GiteaFileContentAndRepoFetcher, config *ConfigFile) (AutogitConfigs, error) {
configs := make([]*AutogitConfig, 0, len(config.GitProjectNames))
var errs []error
for _, git_project := range config.GitProjectNames {
c, err := ReadWorkflowConfig(gitea, git_project)
if err != nil {
// can't sync, so ignore for now
errs = append(errs, err)
log.Println(err)
} else {
configs = append(configs, c)
}
}
if len(errs) > 0 {
return configs, errors.Join(errs...)
}
return configs, nil
}

View File

@@ -768,6 +768,10 @@ func (gitea *GiteaTransport) RequestReviews(pr *models.PullRequest, reviewers ..
return nil, fmt.Errorf("Cannot create pull request reviews: %w", err)
}
// Invalidate the timeline cache so the next GetTimeline call reflects
// the newly created review_requested entry.
gitea.ResetTimelineCache(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index)
return review.GetPayload(), nil
}
@@ -776,6 +780,13 @@ func (gitea *GiteaTransport) UnrequestReview(org, repo string, id int64, reviwer
repository.NewRepoDeletePullReviewRequestsParams().WithOwner(org).WithRepo(repo).WithIndex(id).WithBody(&models.PullReviewRequestOptions{
Reviewers: reviwers,
}), gitea.transport.DefaultAuthentication)
if err == nil {
// Invalidate the timeline cache so the next GetTimeline call reflects
// the newly created review_request_removed entry.
gitea.ResetTimelineCache(org, repo, id)
}
return err
}
@@ -861,24 +872,31 @@ func (gitea *GiteaTransport) GetTimeline(org, repo string, idx int64) ([]*models
prID := fmt.Sprintf("%s/%s!%d", org, repo, idx)
giteaTimelineCacheMutex.RLock()
TimelineCache, IsCached := giteaTimelineCache[prID]
var LastCachedTime strfmt.DateTime
if IsCached {
l := len(TimelineCache.data)
if l > 0 {
LastCachedTime = TimelineCache.data[0].Updated
}
// cache data for 5 seconds
if TimelineCache.lastCheck.Add(time.Second*5).Compare(time.Now()) > 0 {
giteaTimelineCacheMutex.RUnlock()
return TimelineCache.data, nil
}
if IsCached && TimelineCache.lastCheck.Add(time.Second*5).Compare(time.Now()) > 0 {
giteaTimelineCacheMutex.RUnlock()
return TimelineCache.data, nil
}
giteaTimelineCacheMutex.RUnlock()
giteaTimelineCacheMutex.Lock()
defer giteaTimelineCacheMutex.Unlock()
// Re-read after acquiring the write lock: another goroutine may have
// already refreshed the cache while we were waiting.
TimelineCache, IsCached = giteaTimelineCache[prID]
if IsCached && TimelineCache.lastCheck.Add(time.Second*5).Compare(time.Now()) > 0 {
return TimelineCache.data, nil
}
// Find the highest Updated timestamp across all cached items so the
// incremental fetch picks up both new entries and modified ones.
var LastCachedTime strfmt.DateTime
for _, d := range TimelineCache.data {
if time.Time(d.Updated).Compare(time.Time(LastCachedTime)) > 0 {
LastCachedTime = d.Updated
}
}
for resCount > 0 {
opts := issue.NewIssueGetCommentsAndTimelineParams().WithOwner(org).WithRepo(repo).WithIndex(idx).WithPage(&page)
if !LastCachedTime.IsZero() {

View File

@@ -327,7 +327,6 @@ func main() {
interval := flag.Int64("interval", 10, "Notification polling interval in minutes (min 1 min)")
configFile := flag.String("config", "", "PrjGit listing config file")
logging := flag.String("logging", "info", "Logging level: [none, error, info, debug]")
exitOnConfigError := flag.Bool("exit-on-config-error", false, "Exit if any repository in configuration cannot be resolved")
flag.BoolVar(&common.IsDryRun, "dry", false, "Dry run, no effect. For debugging")
flag.Parse()
@@ -383,10 +382,8 @@ func main() {
giteaTransport := common.AllocateGiteaTransport(*giteaUrl)
configs, err := common.ResolveWorkflowConfigs(giteaTransport, configData)
if err != nil {
common.LogError("Failed to resolve some configuration repositories:", err)
if *exitOnConfigError {
return
}
common.LogError("Cannot parse workflow configs:", err)
return
}
reviewer, err := giteaTransport.GetCurrentUser()

View File

@@ -70,7 +70,7 @@ wait_healthy:
@echo "All services are healthy!"
pytest:
podman-compose exec tester pytest -v tests/*
podman-compose exec tester pytest -v tests
build:
podman pull docker.io/library/rabbitmq:3.13.7-management

View File

@@ -81,6 +81,11 @@ services:
init: true
networks:
- gitea-network
healthcheck:
test: ["CMD-SHELL", "pgrep workflow-pr && [ $$(awk '{print $22}' /proc/$$(pgrep workflow-pr)/stat) -lt $$(($$(awk '{print $1}' /proc/uptime | cut -d. -f1)*100 - 1000)) ]"]
interval: 5s
timeout: 5s
retries: 5
depends_on:
gitea:
condition: service_started
@@ -96,7 +101,6 @@ services:
- ./workflow-pr-repos:/var/lib/workflow-pr/repos:Z
command: [
"-check-on-start",
"-exit-on-config-error",
"-debug",
"-gitea-url", "http://gitea-test:3000",
"-url", "amqps://rabbitmq-test:5671",
@@ -130,9 +134,7 @@ services:
networks:
- gitea-network
depends_on:
gitea:
condition: service_started
tester:
workflow-pr:
condition: service_started
environment:
- OBS_USER=mock

View File

@@ -162,6 +162,10 @@ BRANCH_CONFIG_CUSTOM = {
"workflow.config": {
"MergeMode": "devel"
}
},
"zz-ready-to-start": {
"workflow.config": {
}
}
}

View File

@@ -129,7 +129,8 @@ class GiteaAPIClient:
"gitignores": "Go",
"license": "MIT",
"private": False,
"readme": "Default"
"readme": "Default",
"object_format_name": "sha256"
}
response, duration = self._request("POST", f"orgs/{org_name}/repos", json=data)
vprint(f"[{duration:.3f}s] Repository '{org_name}/{repo_name}' created with a README.")
@@ -179,7 +180,7 @@ class GiteaAPIClient:
diff_content = f"""diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..f1838bd
index 00000000..f1838bd9
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
@@ -191,14 +192,14 @@ index 0000000..f1838bd
+ url = ../../mypool/pkgB.git
diff --git a/pkgA b/pkgA
new file mode 160000
index 0000000..{pkg_a_sha}
index 00000000..{pkg_a_sha}
--- /dev/null
+++ b/pkgA
@@ -0,0 +1 @@
+Subproject commit {pkg_a_sha}
diff --git a/pkgB b/pkgB
new file mode 160000
index 0000000..{pkg_b_sha}
index 00000000..{pkg_b_sha}
--- /dev/null
+++ b/pkgB
@@ -0,0 +1 @@

View File

@@ -14,7 +14,7 @@ from tests.lib.common_test_utils import (
def test_pr_workflow_succeeded(staging_main_env, mock_build_result):
"""End-to-end test for a successful PR workflow."""
gitea_env, test_full_repo_name, merge_branch_name = staging_main_env
diff = "diff --git a/test.txt b/test.txt\nnew file mode 100644\nindex 0000000..e69de29\n"
diff = "diff --git a/test.txt b/test.txt\nnew file mode 100644\nindex 00000000..473a0f4c\n"
pr = gitea_env.create_gitea_pr("mypool/pkgA", diff, "Test PR - should succeed", False, base_branch=merge_branch_name)
initial_pr_number = pr["number"]
@@ -59,7 +59,7 @@ def test_pr_workflow_succeeded(staging_main_env, mock_build_result):
def test_pr_workflow_failed(staging_main_env, mock_build_result):
"""End-to-end test for a failed PR workflow."""
gitea_env, test_full_repo_name, merge_branch_name = staging_main_env
diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n"
diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 00000000..473a0f4c\n"
pr = gitea_env.create_gitea_pr("mypool/pkgA", diff, "Test PR - should fail", False, base_branch=merge_branch_name)
initial_pr_number = pr["number"]

View File

@@ -27,7 +27,7 @@ def test_001_project_pr_labels(label_env, staging_bot_client):
# 1. Create a package PR
diff = """diff --git a/label_test_fixture.txt b/label_test_fixture.txt
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
print(f"--- Creating package PR in mypool/pkgA on branch {branch_name} ---")
package_pr = gitea_env.create_gitea_pr("mypool/pkgA", diff, "Test Labels Fixture", False, base_branch=branch_name)

View File

@@ -17,7 +17,7 @@ def test_001_automerge(automerge_env, test_user_client):
# 1. Create a package PR
diff = """diff --git a/automerge_test.txt b/automerge_test.txt
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
print(f"--- Creating package PR in mypool/pkgA on branch {merge_branch_name} ---")
package_pr = test_user_client.create_gitea_pr("mypool/pkgA", diff, "Test Automerge Fixture", False, base_branch=merge_branch_name)
@@ -49,7 +49,7 @@ def test_002_manual_merge(manual_merge_env, test_user_client, usera_client, stag
# 1. Create a package PR
diff = """diff --git a/manual_merge_test.txt b/manual_merge_test.txt
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
print(f"--- Creating package PR in mypool/pkgA on branch {merge_branch_name} ---")
package_pr = gitea_env.create_gitea_pr("mypool/pkgA", diff, "Test Manual Merge Fixture", False, base_branch=merge_branch_name)
@@ -149,7 +149,7 @@ def test_003_refuse_manual_merge(manual_merge_env, test_user_client, ownerB_clie
# 1. Create a package PR
diff = """diff --git a/manual_merge_test.txt b/manual_merge_test.txt
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
print(f"--- Creating package PR in mypool/pkgA on branch {merge_branch_name} ---")
package_pr = test_user_client.create_gitea_pr("mypool/pkgA", diff, "Test Manual Merge Fixture", False, base_branch=merge_branch_name)
@@ -245,7 +245,7 @@ def test_008_merge_mode_ff_only_success(merge_ff_env, test_user_client):
# 1. Create a package PR (this will be FF-mergeable by default)
diff = """diff --git a/ff_test.txt b/ff_test.txt
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
package_pr = test_user_client.create_gitea_pr("mypool/pkgA", diff, "Test FF Merge", False, base_branch=merge_branch_name)
package_pr_number = package_pr["number"]
@@ -269,7 +269,7 @@ def test_009_merge_mode_ff_only_failure(merge_ff_env, ownerA_client):
# 1. Create a package PR that adds a file
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
@@ -291,7 +291,7 @@ index 0000000..e69de29
print("Pushing another change to PR branch to trigger sync...")
gitea_env.modify_gitea_pr("mypool/pkgA", package_pr_number,
"diff --git a/sync_test.txt b/sync_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n",
"diff --git a/sync_test.txt b/sync_test.txt\nnew file mode 100644\nindex 00000000..473a0f4c\n",
"Trigger Sync")
# The bot should detect it's not FF and NOT merge, and re-request reviews because of the new commit
@@ -323,7 +323,7 @@ def test_010_merge_mode_devel_success(merge_devel_env, ownerA_client):
# 1. Create a package PR that adds a file
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
@@ -365,7 +365,7 @@ def test_011_merge_mode_replace_success(merge_replace_env, ownerA_client):
# 1. Create a package PR that adds a file
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
@@ -419,7 +419,7 @@ def test_012_merge_mode_devel_ff_success(merge_devel_env, ownerA_client):
# 1. Create a package PR (this will be FF-mergeable by default)
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@
@@ -461,7 +461,7 @@ def test_013_merge_mode_replace_ff_success(merge_replace_env, ownerA_client):
# 1. Create a package PR (this will be FF-mergeable by default)
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
--- /dev/null
+++ b/{filename}
@@ -0,0 +1 @@

View File

@@ -20,7 +20,7 @@ def test_001_review_requests_matching_config(automerge_env, ownerA_client):
filename = f"pkgB_test_{ts}.txt"
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
print(f"--- Creating package PR in mypool/pkgB on branch {branch_name} as ownerA ---")
package_pr = ownerA_client.create_gitea_pr("mypool/pkgB", diff, "Test Review Requests Config", True, base_branch=branch_name)
@@ -86,7 +86,7 @@ def test_004_maintainer(maintainer_env, ownerA_client):
filename = f"maintainer_test_{ts}.txt"
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
print(f"--- Creating package PR in mypool/pkgA on branch {branch_name} as ownerA ---")
package_pr = ownerA_client.create_gitea_pr("mypool/pkgA", diff, "Test Maintainer Merge", True, base_branch=branch_name)
@@ -155,7 +155,7 @@ def test_005_any_maintainer_approval_sufficient(maintainer_env, ownerA_client, o
filename = f"pkgB_test_{ts}.txt"
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
print(f"--- Creating package PR in mypool/pkgB on branch {branch_name} as ownerA ---")
package_pr = ownerA_client.create_gitea_pr("mypool/pkgB", diff, "Test Single Maintainer Merge", True, base_branch=branch_name)
@@ -217,7 +217,7 @@ def test_006_maintainer_rejection_removes_other_requests(maintainer_env, ownerA_
filename = f"pkgB_rejection_test_{ts}.txt"
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
print(f"--- Creating package PR in mypool/pkgB on branch {branch_name} as ownerA ---")
package_pr = ownerA_client.create_gitea_pr("mypool/pkgB", diff, "Test Maintainer Rejection", True, base_branch=branch_name)
@@ -277,7 +277,7 @@ def test_007_review_required_needs_all_approvals(review_required_env, ownerA_cli
filename = f"pkgB_review_required_test_{ts}.txt"
diff = f"""diff --git a/{filename} b/{filename}
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
"""
print(f"--- Creating package PR in mypool/pkgB on branch {branch_name} as ownerA ---")
package_pr = ownerA_client.create_gitea_pr("mypool/pkgB", diff, "Test Review Required", True, base_branch=branch_name)

View File

@@ -22,7 +22,7 @@ pytest.forwarded_pr_number = None
@pytest.mark.dependency()
def test_001_project_pr(gitea_env):
"""Forwarded PR correct title"""
diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 0000000..e69de29\n"
diff = "diff --git a/another_test.txt b/another_test.txt\nnew file mode 100644\nindex 00000000..473a0f4c\n"
pytest.pr = gitea_env.create_gitea_pr("mypool/pkgA", diff, "Test PR", False)
pytest.initial_pr_number = pytest.pr["number"]
time.sleep(5) # Give Gitea some time to process the PR and make the timeline available
@@ -114,7 +114,7 @@ def test_005_NoProjectGitPR_edits_disabled(no_project_git_pr_env, test_user_clie
# 1. Create a Package PR (without "Allow edits from maintainers" enabled)
initial_diff = """diff --git a/first_file.txt b/first_file.txt
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
--- /dev/null
+++ b/first_file.txt
@@ -0,0 +1 @@
@@ -160,7 +160,7 @@ index {pkgA_main_sha[:7]}..{pkgA_pr_head_sha[:7]} 160000
# 4. Trigger an update on the Package PR to prompt the bot to react to the manual Project PR
new_diff_content = """diff --git a/trigger_bot.txt b/trigger_bot.txt
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
--- /dev/null
+++ b/trigger_bot.txt
@@ -0,0 +1 @@
@@ -200,7 +200,7 @@ def test_006_NoProjectGitPR_edits_enabled(no_project_git_pr_env, test_user_clien
# 2. Create a Package PR with "Allow edits from maintainers" enabled
diff = """diff --git a/new_feature.txt b/new_feature.txt
new file mode 100644
index 0000000..e69de29
index 00000000..473a0f4c
--- /dev/null
+++ b/new_feature.txt
@@ -0,0 +1 @@

View File

@@ -19,22 +19,18 @@ export GITEA_TOKEN
echo "GITEA_TOKEN exported (length: ${#GITEA_TOKEN})"
# Wait for the dummy data to be created by the gitea setup script
echo "Waiting for workflow.config in myproducts/mySLFO..."
API_URL="http://gitea-test:3000/api/v1/repos/myproducts/mySLFO/contents/workflow.config"
echo "Waiting for workflow.config in myproducts/mySLFO (branch zz-ready-to-start)..."
API_URL="http://gitea-test:3000/api/v1/repos/myproducts/mySLFO/contents/workflow.config?ref=zz-ready-to-start"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$API_URL")
WAITED=false
while [ "$HTTP_STATUS" != "200" ]; do
WAITED=true
echo "workflow.config not found yet (HTTP Status: $HTTP_STATUS). Retrying in 5s..."
sleep 5
echo "workflow.config on zz-ready-to-start not found yet (HTTP Status: $HTTP_STATUS). Retrying in 1s..."
sleep 1
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$API_URL")
done
if [ "$WAITED" = true ]; then
echo "workflow.config found. Sleeping 15s to let other configurations settle..."
sleep 15
fi
echo "workflow.config found on zz-ready-to-start."
# Wait for the shared SSH key to be generated by the gitea setup script
echo "Waiting for /var/lib/gitea/ssh-keys/id_ed25519..."

View File

@@ -503,10 +503,7 @@ func updateConfiguration(configFilename string, orgs *[]string) {
os.Exit(4)
}
configs, err := common.ResolveWorkflowConfigs(gitea, configFile)
if err != nil {
common.LogError("Failed to resolve some configuration repositories:", err)
}
configs, _ := common.ResolveWorkflowConfigs(gitea, configFile)
configuredRepos = make(map[string][]*common.AutogitConfig)
*orgs = make([]string, 0, 1)
for _, c := range configs {

View File

@@ -58,7 +58,6 @@ func main() {
checkOnStart := flag.Bool("check-on-start", common.GetEnvOverrideBool(os.Getenv("AUTOGITS_CHECK_ON_START"), false), "Check all repositories for consistency on start, without delays")
checkIntervalHours := flag.Float64("check-interval", 5, "Check interval (+-random delay) for repositories for consitency, in hours")
flag.BoolVar(&ListPROnly, "list-prs-only", false, "Only lists PRs without acting on them")
exitOnConfigError := flag.Bool("exit-on-config-error", false, "Exit if any repository in configuration cannot be resolved")
flag.Int64Var(&PRID, "id", -1, "Process only the specific ID and ignore the rest. Use for debugging")
basePath := flag.String("repo-path", common.GetEnvOverrideString(os.Getenv("AUTOGITS_REPO_PATH"), ""), "Repository path. Default is temporary directory")
pr := flag.String("only-pr", "", "Only specific PR to process. For debugging")
@@ -98,10 +97,8 @@ func main() {
configs, err := common.ResolveWorkflowConfigs(Gitea, config)
if err != nil {
common.LogError("Failed to resolve some configuration repositories:", err)
if *exitOnConfigError {
return
}
common.LogError("Cannot resolve config files:", err)
return
}
for _, c := range configs {

View File

@@ -8,6 +8,7 @@ import (
"runtime/debug"
"slices"
"strings"
"sync"
"time"
"github.com/opentracing/opentracing-go/log"
@@ -628,9 +629,29 @@ func (pr *PRProcessor) Process(req *models.PullRequest) error {
return err
}
// prLocks serialises concurrent processing of the same PR.
// Both the RabbitMQ event loop and the consistency-checker goroutine call
// ProcesPullRequest; without this lock they can race on reviewer add/remove.
// Key format: "org/repo#num"
var prLocks sync.Map // map[string]chan struct{}
func prLockKey(pr *models.PullRequest) string {
return fmt.Sprintf("%s/%s#%d", pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index)
}
func acquirePRLock(key string) chan struct{} {
v, _ := prLocks.LoadOrStore(key, make(chan struct{}, 1))
ch := v.(chan struct{})
ch <- struct{}{}
return ch
}
func releasePRLock(ch chan struct{}) {
<-ch
}
type RequestProcessor struct {
configuredRepos map[string][]*common.AutogitConfig
recursive int
}
func (w *RequestProcessor) Process(pr *models.PullRequest) error {
@@ -647,6 +668,9 @@ func ProcesPullRequest(pr *models.PullRequest, configs []*common.AutogitConfig)
return nil
}
lock := acquirePRLock(prLockKey(pr))
defer releasePRLock(lock)
PRProcessor, err := AllocatePRProcessor(pr, configs)
if err != nil {
log.Error(err)
@@ -663,17 +687,23 @@ func (w *RequestProcessor) ProcessFunc(request *common.Request) (err error) {
common.LogInfo("panic cought --- recovered")
common.LogError(string(debug.Stack()))
}
w.recursive--
}()
w.recursive++
if w.recursive > 3 {
common.LogError("Recursion limit reached... something is wrong with this PR?")
return nil
}
var pr *models.PullRequest
if req, ok := request.Data.(*common.PullRequestWebhookEvent); ok {
// Skip pull_request_sync events triggered by the bot's own pushes to
// prjgit branches. Those would re-run AssignReviewers immediately
// after the bot itself just set them, producing spurious add/remove
// cycles. Human-triggered syncs have a different sender and are still
// processed normally.
if request.Type == common.RequestType_PRSync && CurrentUser != nil &&
req.Sender.Username == CurrentUser.UserName {
common.LogDebug("Skipping self-triggered pull_request_sync from", req.Sender.Username,
"on", req.Pull_Request.Base.Repo.Owner.Username+"/"+req.Pull_Request.Base.Repo.Name,
"#", req.Pull_Request.Number)
return nil
}
pr, err = Gitea.GetPullRequest(req.Pull_Request.Base.Repo.Owner.Username, req.Pull_Request.Base.Repo.Name, req.Pull_Request.Number)
if err != nil {
common.LogError("Cannot find PR for issue:", req.Pull_Request.Base.Repo.Owner.Username, req.Pull_Request.Base.Repo.Name, req.Pull_Request.Number)
@@ -710,8 +740,16 @@ func (w *RequestProcessor) ProcessFunc(request *common.Request) (err error) {
common.LogError("*** Cannot find config for org:", pr.Base.Repo.Owner.UserName)
}
if err = ProcesPullRequest(pr, configs); err == updatePrjGitError_requeue {
time.Sleep(time.Second * 5)
return w.ProcessFunc(request)
// Retry after a delay in a background goroutine so the event loop is
// not blocked while we wait. The per-PR lock inside ProcesPullRequest
// ensures no other processing races with the retry.
go func() {
time.Sleep(time.Second * 5)
if err := ProcesPullRequest(pr, configs); err != nil {
common.LogError("requeue retry failed:", err)
}
}()
return nil
}
return err
}

View File

@@ -989,18 +989,6 @@ func TestProcessFunc(t *testing.T) {
}
})
t.Run("Recursion limit", func(t *testing.T) {
reqProc.recursive = 3
err := reqProc.ProcessFunc(&common.Request{})
if err != nil {
t.Errorf("Expected nil error on recursion limit, got %v", err)
}
if reqProc.recursive != 3 {
t.Errorf("Expected recursive to be 3, got %d", reqProc.recursive)
}
reqProc.recursive = 0 // Reset
})
t.Run("Invalid data format", func(t *testing.T) {
err := reqProc.ProcessFunc(&common.Request{Data: nil})
if err == nil || !strings.Contains(err.Error(), "Invalid data format") {