Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05bd6b3098 | |||
| 0bc41713d4 |
@@ -348,6 +348,30 @@ def post_comment(pr_number: int, body: str, *, dry_run: bool) -> None:
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/issues/{pr_number}/comments", body={"body": body})
|
||||
|
||||
|
||||
def add_hold_label(pr_number: int, dry_run: bool) -> bool:
|
||||
"""Apply the merge-queue-hold label to a PR. Returns True if the label
|
||||
was added (or was already present)."""
|
||||
if not HOLD_LABEL:
|
||||
return False
|
||||
print(f"::notice::adding `{HOLD_LABEL}` to PR #{pr_number}")
|
||||
if dry_run:
|
||||
return True
|
||||
try:
|
||||
api(
|
||||
"POST",
|
||||
f"/repos/{OWNER}/{NAME}/issues/{pr_number}/labels",
|
||||
body={"labels": [HOLD_LABEL]},
|
||||
)
|
||||
return True
|
||||
except ApiError as exc:
|
||||
# 404 = PR already closed/deleted; 422 = label already present (Gitea
|
||||
# returns 422 for duplicate label assignment — not a real error).
|
||||
if "404" in str(exc) or "422" in str(exc):
|
||||
return True
|
||||
sys.stderr.write(f"::warning::could not add hold label to PR #{pr_number}: {exc}\n")
|
||||
return False
|
||||
|
||||
|
||||
def update_pull(pr_number: int, *, dry_run: bool) -> None:
|
||||
print(f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}")
|
||||
if dry_run:
|
||||
@@ -444,6 +468,24 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return 0
|
||||
if decision.action == "wait":
|
||||
# Required contexts are not green. Auto-hold so the queue stops cycling
|
||||
# on this PR and processes the next. Holds are removed manually once the
|
||||
# blocker (e.g. qa/sec gate, missing SOP_TIER_CHECK_TOKEN) is resolved.
|
||||
# Distinguish "not all required status checks successful" 405 (merge
|
||||
# attempted → add hold + comment) from permanent permission errors.
|
||||
add_hold_label(pr_number, dry_run=dry_run)
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
f"merge-queue: auto-held — required contexts not green: "
|
||||
f"{decision.reason}. "
|
||||
"Remove the `merge-queue-hold` label and re-label `merge-queue` "
|
||||
"to restart queue processing once the blocker is resolved."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return 0
|
||||
if decision.ready:
|
||||
latest_main_sha = get_branch_head(WATCH_BRANCH)
|
||||
if latest_main_sha != main_sha:
|
||||
@@ -455,21 +497,38 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
try:
|
||||
merge_pull(pr_number, dry_run=dry_run)
|
||||
except MergePermissionError as exc:
|
||||
# Permanent merge failure (HTTP 403/404/405). Post a comment so
|
||||
# maintainers know why, then return 0 so this tick is done.
|
||||
# The PR stays in the queue; future ticks can retry after the
|
||||
# permission issue is resolved.
|
||||
# Permanent merge failure (HTTP 403/404/405). Distinguish the
|
||||
# Gitea-internal "status check gate" 405 (merge attempted, gate
|
||||
# blocked) from a genuine permission error.
|
||||
msg_lower = str(exc).lower()
|
||||
is_status_check_failure = "not all required status checks successful" in msg_lower
|
||||
sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n")
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
"merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. "
|
||||
"No available token has Can-merge permission on this repo. "
|
||||
"Fix: grant Can-merge to a token, or add a maintain/admin collaborator. "
|
||||
"Skipping to next queued PR on next tick."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
if is_status_check_failure:
|
||||
# Merge API returned 405 because a required status check (e.g.
|
||||
# qa-review, security-review) was still failing at merge time.
|
||||
# Auto-hold so the queue stops cycling and processes the next PR.
|
||||
add_hold_label(pr_number, dry_run=dry_run)
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
"merge-queue: merge attempt blocked by Gitea's required-status-check "
|
||||
"gate (HTTP 405 'not all required status checks successful'). "
|
||||
"Auto-held — remove `merge-queue-hold` and re-label `merge-queue` "
|
||||
"once the blocking checks pass."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
else:
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
"merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. "
|
||||
"No available token has Can-merge permission on this repo. "
|
||||
"Fix: grant Can-merge to a token, or add a maintain/admin collaborator. "
|
||||
"Skipping to next queued PR on next tick."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return 0
|
||||
return 0
|
||||
return 0
|
||||
|
||||
@@ -128,3 +128,82 @@ def test_MergePermissionError_message_preserved():
|
||||
exc = mq.MergePermissionError("POST /merge -> HTTP 405: User not allowed")
|
||||
assert "405" in str(exc)
|
||||
assert "User not allowed" in str(exc)
|
||||
|
||||
|
||||
def test_merge_decision_waits_when_required_contexts_not_green():
|
||||
"""When a required context (e.g. CI / all-required) is not success, the
|
||||
decision is 'wait' — the queue can then auto-hold on this."""
|
||||
required = [
|
||||
"CI / all-required (pull_request)",
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
]
|
||||
decision = mq.evaluate_merge_readiness(
|
||||
main_status={
|
||||
"state": "success",
|
||||
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
|
||||
},
|
||||
pr_status={
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{"context": "CI / all-required (pull_request)", "status": "failure"},
|
||||
{"context": "sop-checklist / all-items-acked (pull_request)", "status": "success"},
|
||||
],
|
||||
},
|
||||
required_contexts=required,
|
||||
pr_has_current_base=True,
|
||||
pr_labels=None,
|
||||
)
|
||||
assert decision.ready is False
|
||||
assert decision.action == "wait"
|
||||
assert "CI / all-required" in decision.reason
|
||||
|
||||
|
||||
def test_tier_low_sop_checklist_pending_is_accepted():
|
||||
"""tier:low PRs get soft-fail on sop-checklist: pending is OK."""
|
||||
required = ["sop-checklist / all-items-acked (pull_request)"]
|
||||
statuses = {
|
||||
"sop-checklist / all-items-acked (pull_request)": {
|
||||
"status": "pending",
|
||||
}
|
||||
}
|
||||
ok, missing = mq.required_contexts_green(
|
||||
statuses, required, pr_labels={"tier:low"}
|
||||
)
|
||||
assert ok is True
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_tier_low_sop_checklist_failure_is_not_accepted():
|
||||
"""tier:low soft-fail only covers pending, not actual failure."""
|
||||
required = ["sop-checklist / all-items-acked (pull_request)"]
|
||||
statuses = {
|
||||
"sop-checklist / all-items-acked (pull_request)": {
|
||||
"status": "failure",
|
||||
}
|
||||
}
|
||||
ok, missing = mq.required_contexts_green(
|
||||
statuses, required, pr_labels={"tier:low"}
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_is_tier_low_pending_ok_true():
|
||||
statuses = {
|
||||
"sop-checklist / all-items-acked (pull_request)": {"status": "pending"}
|
||||
}
|
||||
assert mq._is_tier_low_pending_ok(
|
||||
statuses,
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
{"tier:low"},
|
||||
) is True
|
||||
|
||||
|
||||
def test_is_tier_low_pending_ok_not_tier_low():
|
||||
statuses = {
|
||||
"sop-checklist / all-items-acked (pull_request)": {"status": "pending"}
|
||||
}
|
||||
assert mq._is_tier_low_pending_ok(
|
||||
statuses,
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
set(),
|
||||
) is False
|
||||
|
||||
@@ -89,6 +89,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read # required for SOP_TIER_CHECK_TOKEN team-membership probe
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required.
|
||||
|
||||
@@ -16,6 +16,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read # required for SOP_TIER_CHECK_TOKEN team-membership probe
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
|
||||
|
||||
@@ -35,8 +35,8 @@ func insertMCPDelegationRow(ctx context.Context, db *sql.DB, workspaceID, target
|
||||
})
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, request_body, status)
|
||||
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, $6)
|
||||
`, workspaceID, workspaceID, targetID, "Delegating to "+targetID, string(taskJSON), "pending")
|
||||
VALUES ($1, 'delegation', 'delegate', $2, $3, $4, $5::jsonb, 'pending')
|
||||
`, workspaceID, workspaceID, targetID, "Delegating to "+targetID, string(taskJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -195,115 +191,3 @@ func TestExtractA2AText_PriorityArtifactsOverMessage(t *testing.T) {
|
||||
t.Errorf("artifacts should take priority: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// insertMCPDelegationRow tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInsertMCPDelegationRow_Success(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WithArgs("ws-src", "ws-src", "ws-tgt", "Delegating to ws-tgt", sqlmock.AnyArg(), "pending").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
err = insertMCPDelegationRow(context.Background(), mockDB, "ws-src", "ws-tgt", "del-123", "summarise the report")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertMCPDelegationRow_DBError(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).
|
||||
WithArgs("ws-src", "ws-src", "ws-tgt", sqlmock.AnyArg(), sqlmock.AnyArg(), "pending").
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
err = insertMCPDelegationRow(context.Background(), mockDB, "ws-src", "ws-tgt", "del-456", "check the logs")
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// updateMCPDelegationStatus tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateMCPDelegationStatus_Success(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectExec(`UPDATE activity_logs`).
|
||||
WithArgs("completed", "", "ws-src", "del-789").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Should not panic, should not error
|
||||
updateMCPDelegationStatus(context.Background(), mockDB, "ws-src", "del-789", "completed", "")
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMCPDelegationStatus_WithErrorDetail(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectExec(`UPDATE activity_logs`).
|
||||
WithArgs("failed", "timeout", "ws-src", "del-000").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
updateMCPDelegationStatus(context.Background(), mockDB, "ws-src", "del-000", "failed", "timeout")
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMCPDelegationStatus_DBError_LoggedNotReturned(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
mock.ExpectExec(`UPDATE activity_logs`).
|
||||
WithArgs("failed", sqlmock.AnyArg(), "ws-src", "del-abc").
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
// Function returns no value — error is logged, not propagated.
|
||||
// Verify it does not panic.
|
||||
updateMCPDelegationStatus(context.Background(), mockDB, "ws-src", "del-abc", "failed", "connection refused")
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user