Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
96bd0c5
Implement revert detection for already-reviewed edits
Teja-Sri-Surya Oct 23, 2025
6f8d570
Add Utility Function for Approving/Unapproving Pending Changes
Teja-Sri-Surya Oct 24, 2025
648acd6
Resolve merge conflicts with upstream/main for PR #107
Teja-Sri-Surya Oct 24, 2025
cb62ed8
Merge branch 'main' into feature/approval-utility-function
Teja-Sri-Surya Oct 29, 2025
b845264
fix: satisfy CI for PR #107 (E501 wraps, UP035 typing cleanups, suppr…
Teja-Sri-Surya Oct 29, 2025
60561f5
ci: grant pull-requests: write for label job (fix 'Resource not acces…
Teja-Sri-Surya Oct 29, 2025
fd64d26
style: fix E501 and logging formatting in approval utility and tests
Teja-Sri-Surya Oct 29, 2025
0d686be
style(ci): fix W293 blanks and skip label job on forks
Teja-Sri-Surya Oct 29, 2025
f220c77
style: remove trailing whitespace (W293/W291) in revert_detection
Teja-Sri-Surya Oct 29, 2025
a35b519
style: remove trailing whitespace (fix W293/W291)
Teja-Sri-Surya Oct 29, 2025
f03702a
style: wrap long message and clean blank-line whitespace in revert_de…
Teja-Sri-Surya Oct 29, 2025
fb2ab93
style: clean docstring blanks and sort imports (fix W293/I001)
Teja-Sri-Surya Oct 29, 2025
5002f9f
style: apply ruff fixes/format
Teja-Sri-Surya Oct 29, 2025
b863347
style: wrap long line to satisfy E501 in test_pending_changes_review
Teja-Sri-Surya Oct 29, 2025
a7ef785
style: apply ruff formatting to test_pending_changes_review.py
Teja-Sri-Surya Oct 29, 2025
060aaa3
test-compat: re-export revert detection helpers and SupersetQuery; ad…
Teja-Sri-Surya Oct 29, 2025
fc7b0be
style: add trailing newline (fix W292)
Teja-Sri-Surya Oct 29, 2025
0dc21b6
fix: import CheckContext from reviews.autoreview.context to resolve I…
Teja-Sri-Surya Oct 29, 2025
4997d34
tests: stop passing non-model field change_tag_params to PendingRevis…
Teja-Sri-Surya Oct 29, 2025
ccc8d0c
tests: supply required PendingRevision fields (timestamp, age_at_fetc…
Teja-Sri-Surya Oct 29, 2025
cbe2f8e
tests: make SupersetQuery import patchable by tests (import from revi…
Teja-Sri-Surya Oct 29, 2025
a801a4e
style: sort imports in revert_detection to satisfy I001
Teja-Sri-Surya Oct 30, 2025
95995bb
style: ruff import sort in revert_detection (fix I001)
Teja-Sri-Surya Oct 30, 2025
ceb62b1
style: reorder local imports to satisfy I001 (context before package …
Teja-Sri-Surya Oct 30, 2025
f873d45
style: sort imports per ruff (I001)
Teja-Sri-Surya Oct 30, 2025
d793230
chore: remove accidentally committed ruff_errors.txt
Teja-Sri-Surya Oct 30, 2025
9ea00c8
tests: route calls through reviews.autoreview for patching (SupersetQ…
Teja-Sri-Surya Oct 30, 2025
fb272d6
Merge branch 'main' into feature/approval-utility-function
Teja-Sri-Surya Oct 31, 2025
a225039
Merge branch 'main' into feature/approval-utility-function
Teja-Sri-Surya Nov 1, 2025
552e9b5
fix: Move imports to top of file to resolve E402 errors
Teja-Sri-Surya Nov 1, 2025
a75ea85
Merge branch 'main' into feature/approval-utility-function
Teja-Sri-Surya Nov 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ on:
# 👇 Top-level permissions: safe defaults
permissions:
contents: read
pull-requests: write

jobs:
# -------------------------------
Expand Down
18 changes: 18 additions & 0 deletions app/reviewer/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@

PYWIKIBOT_SITE_FAMILY = os.getenv("PYWIKIBOT_SITE_FAMILY", "wikipedia")

# Revert detection configuration
# Enable/disable revert detection for already-reviewed edits
ENABLE_REVERT_DETECTION = os.getenv("ENABLE_REVERT_DETECTION", "True").lower() in (
"true",
"1",
"yes",
)

# Pending changes approval configuration
# Enable/disable dry-run mode for pending changes approval
# When True, only allows approvals on test pages (Merkityt_versiot_-kokeilu/*)
# When False, allows approvals on all pages
PENDING_CHANGES_DRY_RUN = os.getenv("PENDING_CHANGES_DRY_RUN", "True").lower() in (
"true",
"1",
"yes",
)

# ORES model thresholds (global defaults, per-wiki config takes precedence)
ORES_DAMAGING_THRESHOLD = float(os.getenv("ORES_DAMAGING_THRESHOLD", "0.3"))
ORES_GOODFAITH_THRESHOLD = float(os.getenv("ORES_GOODFAITH_THRESHOLD", "0.7"))
Expand Down
31 changes: 31 additions & 0 deletions app/reviews/autoreview/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,32 @@
from __future__ import annotations

# Backwards-compatibility exports for older test imports
from pywikibot.data.superset import SupersetQuery # re-export for tests

from .checks.revert_detection import (
_find_reviewed_revisions_by_sha1,
_parse_revert_params,
check_revert_detection,
)
from .context import CheckContext


def _check_revert_detection(revision, client):
"""Compatibility wrapper matching legacy signature used in tests."""
context = CheckContext(
revision=revision,
client=client,
profile=None,
auto_groups={},
blocking_categories={},
redirect_aliases=[],
)
return check_revert_detection(context)


__all__ = [
"SupersetQuery",
"_check_revert_detection",
"_find_reviewed_revisions_by_sha1",
"_parse_revert_params",
]
193 changes: 193 additions & 0 deletions app/reviews/autoreview/checks/revert_detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
from __future__ import annotations

import json
import logging
from typing import Any

from django.conf import settings

import reviews.autoreview as autoreview

from ..context import CheckContext

"""
Revert detection check for already-reviewed edits.

This check detects when a pending edit is a revert to previously reviewed content
by matching SHA1 content hashes and checking for revert tags.
"""

logger = logging.getLogger(__name__)


def check_revert_detection(context: CheckContext) -> dict[str, Any]:
"""
Check if a revision is a revert to previously reviewed content.

Args:
context: CheckContext containing revision and related data

Returns:
Dict with check result including status, message, and metadata
"""
# Check if revert detection is enabled
if not getattr(settings, "ENABLE_REVERT_DETECTION", True):
return {"status": "skip", "message": "Revert detection is disabled", "metadata": {}}

revision = context.revision
page = revision.page

# Check for revert tags
revert_tags = {"mw-manual-revert", "mw-reverted", "mw-rollback", "mw-undo"}
change_tags = getattr(revision, "change_tags", [])

if not any(tag in change_tags for tag in revert_tags):
return {
"status": "skip",
"message": "No revert tags found",
"metadata": {"change_tags": change_tags},
}

# Parse change tag parameters to get reverted revision IDs
reverted_rev_ids = _parse_revert_params(revision)
if not reverted_rev_ids:
return {
"status": "skip",
"message": "No reverted revision IDs found in change tags",
"metadata": {"change_tags": change_tags},
}

# Check if any of the reverted revisions were previously reviewed
reviewed_revisions = autoreview._find_reviewed_revisions_by_sha1(
context.client, page, reverted_rev_ids
)

if reviewed_revisions:
return {
"status": "approve",
"message": (
f"Revert to previously reviewed content (SHA1: {reviewed_revisions[0]['sha1']})"
),
"metadata": {
"reverted_rev_ids": reverted_rev_ids,
"reviewed_revisions": reviewed_revisions,
"revert_tags": [tag for tag in change_tags if tag in revert_tags],
},
}

return {
"status": "block",
"message": "Revert detected but no previously reviewed content found",
"metadata": {
"reverted_rev_ids": reverted_rev_ids,
"revert_tags": [tag for tag in change_tags if tag in revert_tags],
},
}


def _parse_revert_params(revision) -> list[int]:
"""
Parse change tag parameters to extract reverted revision IDs.

Args:
revision: PendingRevision object

Returns:
List of reverted revision IDs
"""
try:
# Get change tag parameters from revision
change_tag_params = getattr(revision, "change_tag_params", [])
if not change_tag_params:
return []

reverted_ids: list[int] = []

for param_str in change_tag_params:
try:
# Parse JSON parameter
param_data = json.loads(param_str)

# Extract reverted revision IDs
if "oldestRevertedRevId" in param_data:
reverted_ids.append(param_data["oldestRevertedRevId"])
if "newestRevertedRevId" in param_data:
reverted_ids.append(param_data["newestRevertedRevId"])
if "originalRevisionId" in param_data:
reverted_ids.append(param_data["originalRevisionId"])

except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"Failed to parse change tag param: {param_str}, error: {e}")
continue

return list(set(reverted_ids)) # Remove duplicates

except Exception as e:
logger.error(f"Error parsing revert params for revision {revision.revid}: {e}")
return []


def _find_reviewed_revisions_by_sha1(client, page, reverted_rev_ids: list[int]) -> list[dict]:
"""
Find previously reviewed revisions by SHA1 content hash.

This implements @zache-fi's suggested Superset approach:
1. Query MediaWiki database for older reviewed versions by SHA1
2. Check if any of the reverted revisions were previously reviewed

Args:
client: WikiClient instance
page: PendingPage object
reverted_rev_ids: List of reverted revision IDs

Returns:
List of reviewed revision data
"""
if not reverted_rev_ids:
return []

try:
# Execute Superset query to find reviewed revisions by SHA1
# This follows @zache-fi's suggested SQL approach
revid_list = ",".join(str(int(revid)) for revid in reverted_rev_ids)

# ids are validated as integers above; safe to embed
sql_query = (
"SELECT \n"
" MAX(rev_id) as max_reviewable_rev_id_by_sha1, \n"
" rev_page, \n"
" content_sha1, \n"
" MAX(fr_rev_id) as max_old_reviewed_id \n"
"FROM \n"
" revision \n"
" LEFT JOIN flaggedrevs ON rev_id=fr_rev_id\n"
" JOIN slots ON slot_revision_id=rev_id\n"
" JOIN content ON slot_content_id=content_id\n"
"WHERE \n"
f" rev_id IN ({revid_list})" # noqa: S608
"\nGROUP BY \n"
" rev_page, content_sha1\n"
)

# Execute query using SupersetQuery (resolved through package for test patching)
superset = autoreview.SupersetQuery(site=client.site)
results = superset.query(sql_query)

# Filter results where content was previously reviewed
reviewed_revisions: list[dict] = []
for result in results:
if result.get("max_old_reviewed_id") is not None:
reviewed_revisions.append(
{
"sha1": result.get("content_sha1"),
"max_reviewed_id": result.get("max_old_reviewed_id"),
"max_reviewable_id": result.get("max_reviewable_rev_id_by_sha1"),
"page_id": result.get("rev_page"),
}
)

return reviewed_revisions

except Exception as e:
logger.error(f"Error finding reviewed revisions for page {page.pageid}: {e}")
return []
1 change: 1 addition & 0 deletions app/reviews/management/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Management commands for reviews app
1 change: 1 addition & 0 deletions app/reviews/management/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Management commands
93 changes: 93 additions & 0 deletions app/reviews/management/commands/test_pending_changes_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""
Django management command to test pending changes review functionality.

This command allows testing the approve_revision() utility function
with various parameters and configurations.
"""

from django.conf import settings
from django.core.management.base import BaseCommand, CommandError

from reviews.utils.approval import approve_revision


class Command(BaseCommand):
help = "Test pending changes review functionality (approve/unapprove revisions)"

def add_arguments(self, parser):
parser.add_argument(
"--revid", type=int, required=True, help="Revision ID to approve/unapprove"
)
parser.add_argument(
"--comment",
type=str,
default="Test approval via management command",
help='Comment for the review (default: "Test approval via management command")',
)
parser.add_argument(
"--unapprove",
action="store_true",
help="Unapprove the revision instead of approving it",
)
parser.add_argument("--value", type=int, help="Flag value for the review (optional)")
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would happen without making actual changes",
)

def handle(self, *args, **options):
revid = options["revid"]
comment = options["comment"]
unapprove = options["unapprove"]
value = options["value"]
dry_run = options["dry_run"]

# Display current configuration
current_dry_run = getattr(settings, "PENDING_CHANGES_DRY_RUN", True)
self.stdout.write(f"Current PENDING_CHANGES_DRY_RUN setting: {current_dry_run}")

if dry_run:
self.stdout.write(self.style.WARNING("DRY-RUN MODE: No actual changes will be made"))

# Display operation details
operation = "unapprove" if unapprove else "approve"
self.stdout.write(f"Operation: {operation}")
self.stdout.write(f"Revision ID: {revid}")
self.stdout.write(f"Comment: {comment}")
if value is not None:
self.stdout.write(f"Value: {value}")

try:
# Call the approve_revision function
result = approve_revision(
revid=revid, comment=comment, value=value, unapprove=unapprove
)

# Display results
if result["result"] == "success":
if result.get("dry_run", False):
self.stdout.write(self.style.SUCCESS(f"✅ {result['message']}"))
else:
self.stdout.write(self.style.SUCCESS(f"✅ {result['message']}"))
else:
self.stdout.write(self.style.ERROR(f"❌ {result['message']}"))

# Display additional information
if "api_response" in result:
self.stdout.write(f"API Response: {result['api_response']}")

# Display dry-run information
if result.get("dry_run", False):
self.stdout.write(
self.style.WARNING(
"ℹ️ This was a dry-run operation. "
"Set PENDING_CHANGES_DRY_RUN=False to make actual changes."
)
)

except Exception as e:
self.stdout.write(self.style.ERROR(f"❌ Error: {str(e)}"))
raise CommandError(f"Failed to {operation} revision {revid}: {str(e)}")

self.stdout.write(self.style.SUCCESS("✅ Command completed successfully"))
1 change: 1 addition & 0 deletions app/reviews/services/wiki_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def fetch_pending_pages(self, limit: int = 10000) -> list[PendingPage]:
a.actor_name,
a.actor_user,
group_concat(DISTINCT(ctd_name)) AS change_tags,
group_concat(DISTINCT(ct_params)) AS change_tags_params,
group_concat(DISTINCT(ug_group)) AS user_groups,
group_concat(DISTINCT(ufg_group)) AS user_former_groups,
group_concat(DISTINCT(cl_to)) AS page_categories,
Expand Down
Loading