Switch from Pagure to Forge for blocker review discussions
Some checks failed
Run tests and linters / test (pull_request) Successful in 3m13s
Run tests and linters / lint (pull_request) Failing after 2s

This drops Pagure integration for creating and processing blocker discussion
tickets and replaces it with a Fedora Forge (Forgejo) integration.

Additional related changes are:
- Testing is now performed through containers (Forgejo is one of those
  containers now)
- docs/source/blockerbugs-workflow.md is added for a quick architecture overview
  for people and AI
- docs/source/usage.md is added for a similar purpose documenting usage hints
- docs/source/blockerbugscli.rst is extended for all commands
- development setup instructions changed, using a different DB container with
  different variables
- pytest configuration was moved from setup.cfg to pyproject.toml due to syntax
  escaping issues
- a new pyforgejo dependency is added

Related #296
Merges #299
Assisted-By: Claude Code
This commit is contained in:
Jaroslav Groman 2026-01-23 15:16:39 +01:00 committed by Kamil Páral
commit 5a6f2b5736
55 changed files with 7793 additions and 2371 deletions

View file

@ -6,6 +6,27 @@ on:
jobs:
test:
runs-on: fedora
env:
CI: "true"
services:
blockerbugs-test-db:
image: quay.io/fedora/postgresql-16
env:
POSTGRESQL_USER: blockerbugs
POSTGRESQL_PASSWORD: blockerbugs
POSTGRESQL_DATABASE: blockerbugs
ports:
- 5432:5432
blockerbugs-test-forgejo:
image: codeberg.org/forgejo/forgejo:14-rootless
env:
FORGEJO__security__INSTALL_LOCK: "true"
FORGEJO__server__OFFLINE_MODE: "true"
FORGEJO__database__DB_TYPE: sqlite3
FORGEJO__security__SECRET_KEY: test-secret-key-for-testing-only
FORGEJO__security__INTERNAL_TOKEN: test-internal-token-for-testing
ports:
- 3000:3000
container:
# This is chosen to match our production environment
image: quay.io/almalinuxorg/almalinux:9

4
.gitignore vendored
View file

@ -2,6 +2,7 @@
/.atomgrammars
/.idea/
/.ropeproject/
/.vscode
*.swp
# cache files
@ -19,8 +20,11 @@
# test suite
/.coverage
/.pytest_cache/
pytest.log
# local config
.python-version
/.venv
/blockerbugs_db.sqlite*
/*.pgdump
/conf/settings.py

View file

@ -66,7 +66,7 @@ if os.getenv('DEBUG') == 'true':
app.config["DEBUG"] = True
# Make sure config URLs end with a slash, so we have them always in the same format
for key in ['BODHI_URL', 'PAGURE_URL', 'PAGURE_API']:
for key in ['BODHI_URL', 'FORGEJO_URL', 'FORGEJO_API']:
if not app.config[key].endswith('/'):
app.config[key] = app.config[key] + '/'
@ -113,7 +113,7 @@ setup_logging()
# version check
if _version.get_versions()['error']:
app.logger.warn('Could not reliably figure out app version, the error was: {}'.format(
app.logger.warning('Could not reliably figure out app version, the error was: {}'.format(
_version.get_versions()['error']))
# database

View file

@ -11,10 +11,8 @@ from blockerbugs.models.release import Release
from blockerbugs.util.bug_sync import BugSync
from blockerbugs.util.update_sync import UpdateSync
from blockerbugs.util.bz_interface import BlockerBugs
import blockerbugs.util.discussion_sync as discussion_sync
import blockerbugs.util.pagure_bot as pagure_bot
from blockerbugs.models.bug import Bug
from blockerbugs.util import testdata
from blockerbugs.util import testdata, discussion_sync, forgejo_bot
from alembic.config import Config as al_Config
from alembic import command as al_command
from alembic import script
@ -77,7 +75,7 @@ def initialize_db(destructive=False):
return False
def upgrade_db(args):
def upgrade_db(args): # pylint: disable=unused-argument
print("Upgrading Database to Latest Revision")
alembic_cfg = get_alembic_config()
al_command.upgrade(alembic_cfg, "head")
@ -131,6 +129,18 @@ def add_milestone(args):
def generate_config(args):
"""Generate initial configuration file with default settings.
Creates a new settings.py file in the conf/ directory with a randomly
generated SECRET_KEY and placeholder values for other required configuration
settings. Exits without changes if the configuration file already exists.
Args:
args: Command line arguments containing dburi (database URI to use)
Raises:
SystemExit: If the configuration file already exists (exits with code 0)
"""
dburi = args.dburi
config_filename = os.path.abspath('conf/settings.py')
@ -138,18 +148,23 @@ def generate_config(args):
print("configuration file %s already exists, exiting" % config_filename)
sys.exit(0)
secret_key = secrets.token_hex()
config_file = open(config_filename, 'w')
config_file.write("SECRET_KEY = '%s'\n" % secret_key)
config_file.write("SQLALCHEMY_DATABASE_URI = '%s'\n" % dburi or '')
# leave places for the other config values generally needed
config_file.write("FAS_ADMIN_GROUP = ''\n")
config_file.write("PAGURE_REPO_TOKEN = ''\n")
config_file.close()
with open(config_filename, 'w', encoding='utf-8') as config_file:
config_file.write(f"SECRET_KEY = '{secrets.token_hex()}'\n")
config_file.write(f"SQLALCHEMY_DATABASE_URI = '{dburi or ''}'\n")
# leave places for the other config values generally needed
config_file.write("FAS_ADMIN_GROUP = ''\n")
def check_blockers():
"""Check for blocker bugs missing from the local database.
Queries Bugzilla for all blocker bugs in active milestones and compares them
against the local database. Prints a report for each milestone showing which
blocker bugs (if any) exist in Bugzilla but are missing from the database.
This is useful for verifying that bug synchronization is working correctly
and identifying any bugs that may have been missed during sync operations.
"""
bzinterface = BlockerBugs()
active_milestones = Milestone.query.filter_by(active=True).all()
for milestone in active_milestones:
@ -172,6 +187,20 @@ def check_blockers():
def sync_bugs(args):
"""Synchronize blocker and freeze exception bugs from Bugzilla.
Fetches current blocker and FE bugs from Bugzilla for active milestones and
updates the local database. Currently always performs a full sync (the --full
flag is ignored). Optionally checks for missing blocker bugs after sync.
Args:
args: Command line arguments containing:
- check: If True, verify no blocker bugs are missing after sync
- full: Deprecated, currently ignored (full sync is always performed)
Raises
SystemExit: If no active milestones are found in the database
"""
docheck = args.check
fullsync = args.full
# FIXME: as a workaround for very slow standard sync times, perform a full
@ -186,33 +215,74 @@ def sync_bugs(args):
sys.stderr.write('BugSync ERROR: No active releases found!')
sys.exit(1)
sync = BugSync(db)
sync.update_active(full_sync=fullsync)
bugsync = BugSync(db)
bugsync.update_active(full_sync=fullsync)
if docheck:
print("Checking bug sync status")
check_blockers()
def sync_updates(args):
def sync_updates(args): # pylint: disable=unused-argument
"""Synchronize Bodhi updates for all active releases.
Fetches and syncs update information from Bodhi for all releases marked as
active in the database. This keeps the local database up to date with the
current state of package updates.
Args:
args: Command line arguments (unused)
"""
active_releases = Release.query.filter_by(active=True).all()
update_sync = UpdateSync(db)
for release in active_releases:
update_sync.sync_updates(release)
def sync_discussions(args):
def sync_discussions(args): # pylint: disable=unused-argument
"""Synchronize Forgejo discussion tickets for active bugs.
Creates Forgejo issues for bugs that need discussion (proposed/accepted/rejected
blockers and freeze exceptions) but don't have a discussion_link yet. Reuses
existing discussion links across milestones for the same bug within a release.
Args:
args: Command line arguments (unused)
"""
discussion_sync.sync_discussions()
def sync(args):
"""Synchronize all data: bugs, updates, and discussions.
Performs a complete synchronization by running all three sync operations in sequence:
1. Syncs blocker and FE bugs from Bugzilla
2. Syncs Bodhi updates for active releases
3. Syncs Forgejo discussion tickets for active bugs
Args:
args: Command line arguments passed through to individual sync functions
"""
sync_bugs(args)
sync_updates(args)
sync_discussions(args)
def recreate_discussion(args):
"""Recreate the Forgejo discussion issue for a specific bug.
This command deletes the existing discussion issue (if present) and creates
a new one for the specified bug. Useful for:
- Recovering from corrupted or misconfigured discussion issues
- Resetting discussion state after major changes
- Testing discussion creation logic
Args:
args: Command line arguments containing bugid (the Bugzilla bug ID)
Raises:
SystemExit: If the specified bug is not found in the database
"""
bugid = int(args.bugid)
bug = Bug.query.filter_by(bugid=bugid).first()
@ -224,25 +294,75 @@ def recreate_discussion(args):
def update_discussion(args):
ticket_id = int(args.ticket_id)
pagure_bot.webhook_handler(ticket_id)
"""Manually trigger Forgejo webhook handler to update a discussion issue.
This command processes all comments on the specified Forgejo issue, parses votes,
updates the issue description with current vote tallies, and saves voting data
to the database. Useful for:
- Testing webhook processing without triggering actual webhooks
- Reprocessing votes after bot errors or data corruption
- Manually updating vote summaries when webhook delivery fails
The handler will:
1. Fetch all comments from the Forgejo issue
2. Parse voting commands (BetaBlocker +1, FinalFE -1, etc.)
3. Process admin commands (AGREED, REVOTE) if user is in admin team
4. Update issue description with vote summary
5. Post summary comment if votes were closed via AGREED
6. Save voting data to Bug.votes for all milestones with matching bugid
Args:
args: Command line arguments containing issue_number
"""
issue_number = int(args.issue_number)
forgejo_bot.webhook_handler(issue_number)
def close_inactive_discussions(args):
"""Close Forgejo discussion tickets for all inactive releases.
This command finds all releases marked as inactive (active=False) that haven't
had their discussions closed yet (discussions_closed=False), and closes all
associated Forgejo discussion issues.
For each inactive release, the command will:
1. Find all bugs with Forgejo discussion links
2. Check if the issue is already closed (skip if yes)
3. Close the issue in Forgejo
4. Post a closing comment explaining the release is no longer tracked
5. Mark the release as discussions_closed=True when all issues are closed
Use --dryrun flag to preview which issues would be closed without making changes.
Args:
args: Command line arguments containing optional dryrun flag
"""
discussion_sync.close_discussions_inactive_releases(args.dryrun)
def create_test_data(args):
def create_test_data(args): # pylint: disable=unused-argument
"""Create fake test data in the database"""
testdata.create_test_data()
def remove_test_data(args):
def remove_test_data(args): # pylint: disable=unused-argument
"""Remove fake test data in the database"""
testdata.remove_test_data()
def main():
def main() -> None:
"""Main entry point for the blockerbugs command-line interface.
Sets up argument parsing for all available commands, processes command-line
arguments, and dispatches to the appropriate handler function. Supports a
global --debug flag to enable debug logging.
Available commands include database initialization, milestone/release management,
synchronization operations (bugs, updates, discussions), and test data utilities.
Raises:
SystemExit: If no command is specified (displays help and exits with code 1)
"""
parser = ArgumentParser()
parser.add_argument('--debug', action='store_true', default=False,
@ -323,9 +443,9 @@ def main():
update_discussion_parser = subparsers.add_parser('update-discussion',
help='Update discussion for a given ticket')
update_discussion_parser.add_argument('ticket_id',
metavar='<Ticket ID>',
help='Ticket ID for which to update dicsussion')
update_discussion_parser.add_argument('issue_number',
metavar='<Issue Number>',
help='Issue number for which to update dicsussion')
update_discussion_parser.set_defaults(func=update_discussion)
close_inactive_discussions_parser = subparsers.add_parser('close-inactive-discussions',

View file

@ -63,24 +63,25 @@ class Config(object):
FEDMENU_DATA_URL = ""
BLOCKERBUGS_URL = "https://qa.fedoraproject.org/blockerbugs/"
BLOCKERBUGS_API = "{}api/v0/".format(BLOCKERBUGS_URL)
PAGURE_URL = "https://stg.pagure.io/"
PAGURE_API = "https://stg.pagure.io/api/0/"
PAGURE_REPO = "fedoraqa-test"
PAGURE_REPO_TOKEN = "YOUR SECRET API TOKEN FROM PROJECT SETTINGS"
PAGURE_REPO_WEBHOOK_KEY = "YOUR WEBHOOK KEY PROJECT SETTINGS"
PAGURE_BOT_USERNAME = 'blockerbot'
PAGURE_BOT_ENABLED = True
PAGURE_BOT_LOOP_THRESHOLD = 2
PAGURE_DISCUSSION_TITLE = "[$component] $summary | rhbz#$bugid"
PAGURE_DISCUSSION_CONTENT = '''\
Bug details: ** $bug_url **
FORGEJO_URL = "https://forge.stg.fedoraproject.org/"
FORGEJO_API = "https://forge.stg.fedoraproject.org/api/v1/"
FORGEJO_REPO = "quality/blocker-review"
FORGEJO_BOT_ACCESS_TOKEN = "YOUR SECRET FORGEJO API TOKEN"
FORGEJO_REPO_WEBHOOK_SECRET = "YOUR WEBHOOK SECRET"
FORGEJO_BOT_USERNAME = 'blockerbot'
FORGEJO_BOT_ENABLED = True
FORGEJO_BOT_LOOP_THRESHOLD = 2
FORGEJO_ADMIN_ORG = "quality"
FORGEJO_DISCUSSION_TITLE = "[$component] $summary | rhbz#$bugid"
FORGEJO_DISCUSSION_CONTENT = '''\
Bug details: <strong>$bug_url</strong>
Information from [BlockerBugs App]($blockerbugs_url):
![$bugid]($bug_img)
#### Current vote summary
$vote_summary
To learn how to vote, see:
$pagure_url$pagure_repo
$forgejo_url$forgejo_repo
A quick example: `BetaBlocker +1` (where the tracker name is one of \
`BetaBlocker`/`FinalBlocker`/`BetaFE`/`FinalFE`/`0Day`/`PreviousRelease` \
and the vote is one of `+1`/`0`/`-1`)
@ -94,9 +95,9 @@ class ProductionConfig(Config):
BUGZILLA_URL = 'https://bugzilla.redhat.com'
BODHI_URL = 'https://bodhi.fedoraproject.org/'
OIDC_ENABLED = True
PAGURE_URL = "https://pagure.io/"
PAGURE_API = "https://pagure.io/api/0/"
PAGURE_REPO = "fedora-qa/blocker-review"
FORGEJO_URL = "https://forge.fedoraproject.org/"
FORGEJO_API = "https://forge.fedoraproject.org/api/v1/"
FORGEJO_REPO = "quality/blocker-review"
SHOW_DB_URI = False
OIDC_CLIENT_SECRETS = "/etc/blockerbugs/oidc.json"
@ -126,13 +127,14 @@ def openshift_config(config_object, openshift_production):
)
except KeyError:
print("OpenShift mode enabled but required values couldn't be fetched. "
"Check, if you have these variables defined in you env: "
"Check, if you have these variables defined in your env: "
"(POSTGRESQL_[USER, PASSWORD, DATABASE, SERVICE_HOST, SERVICE_PORT])", file=sys.stderr)
sys.exit(1)
# And then try to get more data from OpenShift env
additional_env_keys = ["FAS_ADMIN_GROUP", "PAGURE_REPO_TOKEN", "PAGURE_REPO_WEBHOOK_KEY",
"PAGURE_REPO", "PAGURE_BOT_USERNAME", "PAGURE_BOT_ENABLED", "PAGURE_URL", "PAGURE_API",
additional_env_keys = ["FAS_ADMIN_GROUP", "FORGEJO_BOT_ACCESS_TOKEN", "FORGEJO_REPO_WEBHOOK_SECRET", "FORGEJO_REPO",
"FORGEJO_BOT_USERNAME", "FORGEJO_BOT_ENABLED", "FORGEJO_URL", "FORGEJO_API",
"FORGEJO_ADMIN_ORG",
"BUGZILLA_URL", "BUGZILLA_API_KEY", "BODHI_URL", "BLOCKERBUGS_URL", "BLOCKERBUGS_API",
"SECRET_KEY"]
missing_data = False

View file

@ -30,9 +30,9 @@ from blockerbugs.models.milestone import Milestone
from blockerbugs.models.update import Update
from blockerbugs.models.release import Release
from blockerbugs.models.bug import Bug
from blockerbugs.util import pagure_bot
from blockerbugs.util import forgejo_bot
from . import errors
from .utils import get_or_404, JsonResponse, SVGResponse, check_signature
from .utils import get_or_404, JsonResponse, SVGResponse, check_forgejo_signature
api_v0 = Blueprint('api', __name__, url_prefix='/api/v0')
@ -46,12 +46,44 @@ ACCEPTED_BUGTYPES = [
@api_v0.errorhandler(errors.RestApiError)
def api_error_handler(e):
def api_error_handler(e: errors.RestApiError) -> JsonResponse:
"""Handle REST API errors and convert them to JSON responses.
This error handler catches all RestApiError exceptions raised within API endpoints
and automatically converts them to properly formatted JSON error responses with
appropriate HTTP status codes.
Args:
e: The RestApiError exception that was raised.
Returns:
JsonResponse: A JSON response containing the error details (as a dictionary)
and the appropriate HTTP status code from the exception.
"""
return JsonResponse(e.to_dict(), e.http_status_code)
def get_update_info(update: Update) -> dict[str, Any]:
"""Create a per-update response dictionary to be used in ``list_updates()``.
Extracts key update information and associated bug/milestone data into a dictionary
suitable for JSON serialization in API responses.
Args:
update: The Update object to extract information from.
Returns:
dict[str, Any]: A dictionary containing:
- updateid: The update identifier
- title: Update title/name
- url: URL to the update (e.g., in Bodhi)
- karma: Current karma score
- stable_karma: Karma threshold for stable
- status: Update status (e.g., 'pending', 'stable', 'testing')
- request: Current update request state
- release: Release number
- milestones: List of associated milestone objects with version and release
- bugs: List of bug objects with bugid and type classifications
"""
update_simple_fields = ['updateid', 'title', 'url', 'karma', 'stable_karma', 'status',
'request']
@ -69,7 +101,25 @@ def get_update_info(update: Update) -> dict[str, Any]:
return update_data
def get_bug_info(bug):
def get_bug_info(bug: Bug) -> dict[str, Any]:
"""Create a per-bug response dictionary to be used in ``list_bugs()``.
Extracts key bug information and type classifications into a dictionary
suitable for JSON serialization in API responses.
Args:
bug: The Bug object to extract information from.
Returns:
dict[str, Any]: A dictionary containing:
- bugid: The Bugzilla bug ID
- url: URL to the bug in Bugzilla
- summary: Bug summary/title
- component: The affected component
- active: Whether the bug is active
- discussion_link: Link to the blocker review discussion
- type: List of bug type classifications (e.g., ['accepted_blocker', 'proposed_fe'])
"""
bug_simple_fields = ['bugid', 'url', 'summary', 'component', 'active', 'discussion_link']
bug_info = dict((attr, getattr(bug, attr)) for attr in bug_simple_fields)
bug_info['type'] = [tp for tp in ACCEPTED_BUGTYPES if getattr(bug, tp)]
@ -78,7 +128,32 @@ def get_bug_info(bug):
@api_v0.route('/milestones/<int:rel_num>/<milestone_version>/updates')
def list_updates(rel_num: int, milestone_version: str) -> JsonResponse:
"""List all Updates which claim to fix a tracked bug created under the specified ``milestone``.
"""List all updates that claim to fix active bugs tracked for a specific milestone.
Retrieves all package updates associated with active bugs for the specified milestone,
optionally filtered by bug type. Results are ordered by submission date (newest first).
Args:
rel_num: The release number (e.g., 40 for Fedora 40).
milestone_version: The milestone version (e.g., 'beta', 'final').
Query Parameters:
bugtype (optional): Filter updates by bug type. Must be one of the accepted bug types
(proposed_blocker, accepted_blocker, rejected_blocker,
proposed_fe, accepted_fe, rejected_fe, accepted_0day,
accepted_prevrel, prioritized).
Returns:
JsonResponse: A JSON array of update objects, each containing update information
including updateid, title, url, karma, status, release, associated
milestones, and bug details with their type classifications.
Raises:
404: If the specified release or milestone is not found.
InvalidArgumentError: If an invalid bugtype is provided in query parameters.
Note:
Only updates associated with active bugs are included in the results.
"""
release = get_or_404(Release, number=rel_num)
milestone = get_or_404(Milestone, release=release,
@ -105,7 +180,31 @@ def list_updates(rel_num: int, milestone_version: str) -> JsonResponse:
@api_v0.route('/milestones/<int:rel_num>/<milestone_version>/bugs')
def list_bugs(rel_num, milestone_version):
def list_bugs(rel_num: int, milestone_version: str) -> JsonResponse:
"""List all bugs tracked for a specific milestone.
Retrieves all bugs associated with the specified milestone, optionally filtered
by bug type. Results are ordered by component name and bug ID.
Args:
rel_num: The release number (e.g., 40 for Fedora 40).
milestone_version: The milestone version (e.g., 'beta', 'final').
Query Parameters:
bugtype (optional): Filter bugs by type. Must be one of the accepted bug types
(proposed_blocker, accepted_blocker, rejected_blocker,
proposed_fe, accepted_fe, rejected_fe, accepted_0day,
accepted_prevrel, prioritized).
Returns:
JsonResponse: A JSON array of bug objects, each containing bug information
including bugid, url, summary, component, active status,
discussion_link, and type classifications.
Raises:
404: If the specified release or milestone is not found.
InvalidArgumentError: If an invalid bugtype is provided in query parameters.
"""
release = get_or_404(Release, number=rel_num)
milestone = get_or_404(Milestone, release=release,
version=milestone_version)
@ -121,52 +220,123 @@ def list_bugs(rel_num, milestone_version):
bugs_info = [get_bug_info(bug) for bug in bugs]
return JsonResponse(bugs_info)
@api_v0.route('/milestones/current')
def get_current_milestone():
def get_current_milestone() -> JsonResponse:
"""Retrieve the currently active milestone.
Returns information about the milestone marked as current in the database.
This typically represents the active development or testing milestone for
blocker bug tracking.
Returns:
JsonResponse: A JSON response containing the current milestone's simplified
representation, including version and release information.
Raises:
404: If no current milestone is found in the database.
"""
current_milestone = get_or_404(Milestone, current=True)
return JsonResponse(current_milestone.simple())
@api_v0.route('/webhook', methods=['POST'])
def pagure_webhook():
if not app.config['PAGURE_BOT_ENABLED']:
msg = 'Pagure bot disabled, ignoring request'
@api_v0.route("/webhook/forgejo", methods=["POST"])
def forgejo_webhook() -> JsonResponse:
"""Handle webhook events from Forgejo for issue comment processing.
This endpoint receives and processes webhook events from Forgejo when issue comments
are created or edited. It validates the webhook signature, filters for relevant events,
and triggers the Forgejo bot to process blocker bug voting commands.
Expected webhook headers:
X-Forgejo-Event: Event type (e.g., 'issue_comment')
X-Forgejo-Signature: HMAC signature for request validation
Processed events:
- issue_comment with action 'created' or 'edited'
Ignored events:
- All other event types
- Comments on closed issues
- Deleted comments
Returns:
JsonResponse: Response object with a status message indicating whether
the webhook was processed, ignored, or rejected.
"""
if not app.config.get("FORGEJO_BOT_ENABLED", False):
msg = 'Forgejo bot disabled, ignoring request'
app.logger.info(msg)
return JsonResponse({'msg': msg})
if not check_signature(request.headers, request.data):
msg = 'Wrong signature, ignoring.'
if not check_forgejo_signature(request.headers, request.get_data()):
msg = "Invalid signature, ignoring."
app.logger.debug(msg)
return JsonResponse({'msg': msg})
data = request.json
if data['topic'] not in ['issue.comment.added', 'issue.comment.edited']:
msg = 'Ignoring message with topic %s' % data['topic']
event = request.headers.get("X-Forgejo-Event", "")
if not data:
msg = f"No data received: {event}"
app.logger.debug(msg)
return JsonResponse({"msg": msg})
# Process issue comment events
if event != "issue_comment":
msg = f"Ignoring event: {event}"
app.logger.debug(msg)
return JsonResponse({'msg': msg})
issue_id = data.get('msg', {}).get('issue', {}).get('id', None)
status = data.get('msg', {}).get('issue', {}).get('status', '')
if not issue_id or not status:
msg = 'Unable to parse received message (isssue id, status)'
action = data.get("action", "") # created, edited, deleted
if action not in ["created", "edited"]:
msg = f"Ignoring issue_comment action: {action}"
app.logger.debug(msg)
return JsonResponse({'msg': msg})
if status == 'Closed':
msg = 'Ignoring a closed issue'
issue_number = data.get("issue", {}).get("number", None)
state = data.get("issue", {}).get("state", "")
if not issue_number or not state:
msg = f"Unable to parse received message (issue number, state) from '{data}'"
app.logger.debug(msg)
return JsonResponse({'msg': msg})
pagure_bot.webhook_handler(issue_id)
msg = 'Message successfully parsed'
if state == "closed":
msg = "Ignoring closed issue"
app.logger.debug(msg)
return JsonResponse({"msg": msg})
forgejo_bot.webhook_handler(issue_number)
msg = "Message successfully parsed"
app.logger.debug(msg)
return JsonResponse({'msg': msg})
return JsonResponse({"msg": msg})
def _svg_response_text(info_all):
svg_template = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="{total_height}" width="300">'\
'{info_lines}'\
'</svg>'
def _svg_response_text(info_all: list[str]) -> str:
"""Generate SVG markup from a list of text strings.
Creates an SVG image with a white background containing vertically stacked text lines.
Each line is rendered in a sans-serif font with consistent spacing.
Args:
info_all: List of strings to render as text lines in the SVG.
Returns:
str: Complete SVG markup string with the specified text lines.
The SVG has a fixed width of 300px and dynamic height based on
the number of text lines (17px per line).
Note:
Layout calculations: Each line is 17px tall, with text rendered at y offset 13px
(4px padding below). The y_offset for the nth line is calculated as 17*n + 13.
"""
svg_template = (
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="{total_height}" width="300">'
'<rect width="100%" height="100%" fill="white"/>'
"{info_lines}"
"</svg>"
)
info_line_template = '<text font-family="sans-serif" x="0" y="{y_offset}" font-size="14px" fill="#212529">{info}</text>'
# height of each line is 17px, text is rendered at y offset 13px (4px bellow as padding)
@ -176,7 +346,19 @@ def _svg_response_text(info_all):
return svg_template.format(total_height=17 * len(info_all), info_lines="".join(info_lines))
def _get_bugtypes(bug):
def _get_bugtypes(bug: Bug) -> list[str]:
"""Extract human-readable bug type classifications from a Bug object.
Checks all possible bug type flags (proposed, accepted, rejected blocker/FE, etc.)
and returns a list of corresponding human-readable labels.
Args:
bug: The Bug object to extract type classifications from.
Returns:
list[str]: List of bug type labels (e.g., "Accepted Blocker", "Proposed FreezeException").
Empty list if the bug has no type flags set. A bug can have multiple types.
"""
bugtypes = []
if bug.proposed_blocker:
bugtypes.append("Proposed Blocker")
@ -197,7 +379,19 @@ def _get_bugtypes(bug):
return bugtypes
def _get_pretty_milestone_name(bug):
def _get_pretty_milestone_name(bug: Bug) -> str:
"""Format a bug's milestone information into a human-readable string.
Generates a formatted milestone prefix showing the Fedora release number
and milestone version.
Args:
bug: The Bug object containing milestone and release information.
Returns:
str: Formatted milestone string in the format "F{number} {CAPITALIZED_VERSION}: "
(e.g., "F40 BETA: ", "F41 FINAL: ") with trailing space.
"""
return "F%d %s: " % (bug.milestone.release.number, bug.milestone.version.capitalize())
@ -206,7 +400,29 @@ _BUG_CLOSED = "BUG CLOSED"
@api_v0.route('/bugimg/<int:bug_id>')
def bug_image(bug_id):
def bug_image(bug_id: int) -> SVGResponse:
"""Generate an SVG image displaying bug status and classification across milestones.
Creates a dynamically generated SVG badge showing the bug's blocker/freeze exception
status for each milestone it's tracked in. The same bug may appear in multiple
milestones with different classifications.
Args:
bug_id: The Bugzilla bug ID to generate the image for.
Returns:
SVGResponse: An SVG image containing:
- "unknown bug" if the bug_id is not found in the database
- "BUG CLOSED" if all entries for this bug are closed
- Per-milestone status showing the milestone name and bug types
(e.g., "F40 Beta: Accepted Blocker, Proposed FreezeException")
Note:
A bug can exist in the database multiple times for different milestones.
Only bugs in active milestones are updated, so bugs in inactive milestones
may still show as CLOSED even if reopened for an active milestone.
"""
bugs = Bug.query.filter_by(bugid=bug_id).all()
if not bugs:
return SVGResponse(_UNKNOWN_BUG_SVG_TEXT)

View file

@ -27,6 +27,7 @@ import hmac
import hashlib
from flask import Response
from werkzeug.datastructures import Headers
from blockerbugs import app
from .errors import MalformedJSONError, NoSuchObjectError
@ -92,9 +93,26 @@ def get_or_404(model, *args, **kwargs):
return rv
def check_signature(headers, payload):
key = bytes(app.config['PAGURE_REPO_WEBHOOK_KEY'], encoding="ascii")
def check_forgejo_signature(headers: Headers, payload: bytes) -> bool:
"""Verify HMAC-SHA256 signature from Forgejo webhook
hashhex = hmac.new(key, payload, hashlib.sha1).hexdigest()
Args:
headers: Request headers
payload: Raw request body bytes
return hashhex == headers.get('X-Pagure-Signature')
Returns:
bool: True if signature is valid
"""
secret = app.config.get('FORGEJO_REPO_WEBHOOK_SECRET')
# Reject if secret is not configured or empty
if not secret:
app.logger.warning("Webhook secret not configured, rejecting webhook")
return False
key = bytes(secret, encoding="ascii")
computed = hmac.new(key, payload, hashlib.sha256).hexdigest()
received = headers.get('X-Forgejo-Signature', '')
# Use constant-time comparison to prevent timing attacks
return hmac.compare_digest(computed, received)

View file

@ -19,15 +19,16 @@
"""Interface for the main page"""
import datetime
import itertools
import json
import flask
from flask import Blueprint, render_template, redirect, url_for, abort, g, make_response, request
import datetime
from sqlalchemy import or_
import json
import itertools
from blockerbugs import app, db, oidc, __version__
from blockerbugs.util import bz_interface, pagure_bot, misc, discussion_sync, bug_sync
from blockerbugs.util import bz_interface, forgejo_bot, misc, discussion_sync, bug_sync
from blockerbugs.models.bug import Bug
from blockerbugs.models.milestone import Milestone
from blockerbugs.models.update import Update
@ -216,7 +217,7 @@ def meeting_voting_info(bugz):
neutrals = [f"{person_vote}" for person_vote in vote['0']]
cons = [f"-{person_vote}" for person_vote in vote['-1']]
people = ", ".join(pros + neutrals + cons)
info += f"!info Ticket vote: {pagure_bot.NICER[tracker]} {summary} ({people})\n"
info += f"!info Ticket vote: {forgejo_bot.NICER[tracker]} {summary} ({people})\n"
voting_info[bugid] = info.strip()
return voting_info
@ -494,14 +495,16 @@ def propose_bug():
bugform.bugid.errors = [e.msg]
# check to make sure that any proposals aren't being re-done
if bugform.blocker.data:
app.logger.debug('checking for valid blocker proposal')
if not proposal.check_blocker_proposal():
bugform.blocker.errors = ['Bug %i is already proposed as a blocker' % bugid]
if bugform.freeze_exception.data:
app.logger.debug('checking for valid freeze exception proposal')
if not proposal.check_fe_proposal():
bugform.freeze_exception.errors = ['Bug %i is already proposed as a freeze exception' % bugid]
# only if the bug validation passed
if len(bugform.bugid.errors) == 0:
if bugform.blocker.data:
app.logger.debug('checking for valid blocker proposal')
if not proposal.check_blocker_proposal():
bugform.blocker.errors = ['Bug %i is already proposed as a blocker' % bugid]
if bugform.freeze_exception.data:
app.logger.debug('checking for valid freeze exception proposal')
if not proposal.check_fe_proposal():
bugform.freeze_exception.errors = ['Bug %i is already proposed as a freeze exception' % bugid]
if len(bugform.errors) == 0:
user = 'Fedora user %s' % g.oidc_user.name

View file

@ -9,7 +9,7 @@ An unsafe character present in an update details! A possible command injection?!
{% endif %}
{%- endfor -%}
{#- Note: Bugzilla ticket numbers use ⧣ instead of # as a prefix, to prevent Pagure from interpreting them as Pagure ticket numbers. -#}
{#- Note: Bugzilla ticket numbers use ⧣ instead of # as a prefix, to prevent Forge from interpreting them as Forge ticket numbers. -#}
## CANDIDATE COMPOSE ##

View file

@ -1,14 +1,27 @@
'''Sync discussion tickets located in Pagure'''
"""Sync discussion tickets located in Forgejo"""
from urllib.parse import urlparse
from blockerbugs import db, app
from blockerbugs.config import Config as bb_Config
from blockerbugs.util import pagure_interface, bz_interface
from blockerbugs.util import forgejo_interface
from blockerbugs.models.milestone import Milestone
from blockerbugs.models.release import Release
from blockerbugs.models.bug import Bug
def needs_discussion(bug):
def needs_discussion(bug: Bug) -> bool:
"""Check if a bug requires a discussion ticket.
A bug needs discussion if it has any blocker or freeze exception status,
including proposed, accepted, or rejected states.
Args:
bug: Bug object to check for discussion requirement
Returns:
bool: True if the bug needs a discussion ticket, False otherwise
"""
return (bug.proposed_blocker
or bug.proposed_fe
or bug.rejected_blocker
@ -19,12 +32,50 @@ def needs_discussion(bug):
or bug.accepted_fe)
def _link_key(milestone, bug):
def _link_key(milestone: Milestone, bug: Bug) -> str:
"""Generate a unique key for a bug's discussion link within a release.
Creates a string key combining the release number and bug ID, used to track
and reuse discussion links across different milestones within the same release.
Args:
milestone: Milestone object containing the release information
bug: Bug object to generate the key for
Returns:
str: A key in the format "releasenumber_bugid" (e.g., "41_123456")
"""
return "%d_%d" % (milestone.release.number, bug.bugid)
def create_discussions_links(milestone, bugs, links_to_reuse={}):
app.logger.info('Creating discussion tickets for %d bugs under %s' % (
def create_discussions_links(
milestone: Milestone, bugs: list[Bug], links_to_reuse: dict[str, str] | None = None
) -> None:
"""Create Forgejo discussion issues for bugs that need discussion.
For each bug that requires discussion (proposed/accepted/rejected blockers
and freeze exceptions), this function either reuses an existing discussion
link from the links_to_reuse cache or creates a new Forgejo issue via the
Forgejo API. The discussion link URL is then saved to the bug's
discussion_link field in the database.
Args:
milestone: Milestone object that the bugs belong to
bugs: List of Bug objects to process
links_to_reuse: Optional dict mapping releasenumber_bugid keys to existing
discussion URLs. If provided, will be READ for existing links
and MUTATED to add newly created links for reuse across milestones.
If None, a new empty dict will be created (and discarded).
Returns:
None. Updates Bug.discussion_link in database and mutates links_to_reuse.
Raises:
Logs errors if Forgejo API calls fail but continues processing other bugs.
"""
links_to_reuse = links_to_reuse or {}
app.logger.info('Creating Forgejo discussion tickets for %d bugs under %s' % (
len(bugs), milestone))
for bug in bugs:
@ -36,13 +87,13 @@ def create_discussions_links(milestone, bugs, links_to_reuse={}):
if link:
app.logger.debug('Reusing discussion link for bug %d' % bug.bugid)
else:
app.logger.debug('Creating Pagure discussion for bug %d' % bug.bugid)
app.logger.debug('Creating Forgejo discussion for bug %d' % bug.bugid)
try:
link = pagure_interface.create_bug_discussion(bug)
link = forgejo_interface.create_bug_discussion(bug)
links_to_reuse[_link_key(milestone, bug)] = link
except pagure_interface.PagureAPIException as e:
app.logger.error('Unable to create Pagure discussion for bug %s. '
'Pagure error: %s' % (bug.bugid, e))
except (forgejo_interface.ForgejoAPIException, ValueError) as e:
app.logger.error('Unable to create Forgejo discussion for bug %s. '
'Forgejo error: %s' % (bug.bugid, e))
continue
bug.discussion_link = link
@ -50,15 +101,42 @@ def create_discussions_links(milestone, bugs, links_to_reuse={}):
db.session.commit()
def sync_discussions():
if app.config["PAGURE_REPO_TOKEN"] in (bb_Config.PAGURE_REPO_TOKEN, ""):
def sync_discussions() -> None:
"""Synchronize Forgejo discussion issues for all active bugs across all active milestones.
This function creates Forgejo discussion issues for bugs that need discussion
(proposed/accepted/rejected blockers and freeze exceptions) but don't yet have
a discussion_link set in the database. It implements smart link reuse: if the
same bug appears in multiple milestones within the same release, it reuses the
same discussion issue URL across those milestones rather than creating duplicates.
The function operates in two phases:
1. First pass: Build a cache of existing discussion links by scanning all active
bugs that already have discussion_link set.
2. Second pass: For bugs without discussion_link, either reuse a cached link or
create a new Forgejo issue via the API.
Args:
None. Reads configuration from app.config.
Returns:
None. Updates Bug.discussion_link in the database for bugs needing discussions.
Raises:
Logs errors if Forgejo API calls fail but continues processing other bugs.
Note:
Skips execution if FORGEJO_BOT_ACCESS_TOKEN is not configured (set to placeholder
or empty string), as API operations would fail without valid authentication.
"""
if app.config["FORGEJO_BOT_ACCESS_TOKEN"] in (bb_Config.FORGEJO_BOT_ACCESS_TOKEN, ""):
# skip this if the API token is set to the placeholder value
# or empty string as we are not going to be able to create anything
app.logger.info('Not syncing discussions, because PAGURE_REPO_TOKEN '
app.logger.info('Not syncing discussions, because FORGEJO_BOT_ACCESS_TOKEN '
'is not configured.')
return
links = {}
links: dict[str, str] = {}
active_milestones = Milestone.query.filter_by(active=True).all()
@ -72,18 +150,60 @@ def sync_discussions():
create_discussions_links(milestone, bugs, links_to_reuse=links)
def recreate_discussion(bugid):
def recreate_discussion(bugid: int) -> None:
"""Recreate Forgejo discussion links for a specific bug across all milestones.
This function finds all instances of a bug (identified by bugid) across all
milestones and recreates their Forgejo discussion links. This is useful when
a discussion link needs to be regenerated, for example after manual deletion
or when troubleshooting discussion sync issues.
Args:
bugid: The bug number to recreate discussions for
Returns:
None. Updates Bug.discussion_link in database for all matching bugs.
"""
milestones = Milestone.query.all()
for milestone in milestones:
bugs = Bug.query.filter_by(bugid=bugid, milestone=milestone).all()
create_discussions_links(milestone, bugs)
def close_discussions_inactive_releases(dry_run=False):
'''Close all Pagure discussion tickets for all inactive releases which are
not yet marked with ``Release.discussions_closed=True``.
'''
app.logger.info('Closing discussion tickets in inactive releases...')
def close_discussions_inactive_releases(dry_run: bool = False) -> None:
"""Close all Forgejo discussion issues for inactive releases.
This function processes all releases that are marked as inactive (active=False)
but haven't had their discussion issues closed yet (discussions_closed=False).
For each such release, it finds all associated bugs with Forgejo discussion links
and closes those issues in Forgejo, posting a comment explaining that the release
is no longer tracked.
The function only processes links what match the configured Forgejo instance
in the configuration. All "unknown" links (and also bugs without a link) are
skipped.
Important: Issues are closed BEFORE posting the comment. This ensures that if a
new version of BlockerBugs is deployed between steps, the old issues won't be
reprocessed with new logic.
Args:
dry_run: If True, logs what would be done without actually closing issues
or updating the database. Defaults to False.
Returns:
None. Updates Release.discussions_closed=True in database when all issues
for a release are successfully closed.
Raises:
Logs errors if Forgejo API calls fail but continues processing other issues.
If any issue fails to close, the release is NOT marked as discussions_closed.
Note:
After all issues in a release are successfully closed (and not in dry_run mode),
sets Release.discussions_closed=True to prevent reprocessing in future runs.
"""
app.logger.info('Closing Forgejo discussion tickets in inactive releases...')
inactive_releases = Release.query.filter_by(active=False, discussions_closed=False).all()
for inactive_release in inactive_releases:
@ -91,40 +211,57 @@ def close_discussions_inactive_releases(dry_run=False):
all_closed = True
milestones = Milestone.query.filter_by(release=inactive_release).all()
for milestone in milestones:
bugs = Bug.query.filter_by(milestone=milestone)
bugs = Bug.query.filter_by(milestone=milestone).all()
for bug in bugs:
if not bug.discussion_link:
app.logger.debug(f"Skipping bug {bug.bugid}, discussion doesn't exist")
continue
try:
# FIXME investigate storing issue_id directly in db instead of complete url
issue_id = bug.discussion_link.split('/')[-1]
app.logger.debug(f'Closing Pagure discussion #{issue_id} for bug {bug.bugid}')
# Only process Forgejo links (verify URL matches configured Forgejo instance and repo)
forgejo_prefix = f"{app.config['FORGEJO_URL']}{app.config['FORGEJO_REPO']}/issues/"
if not bug.discussion_link.startswith(forgejo_prefix):
app.logger.warning(f"Skipping bug {bug.bugid}, unsupported link: "
f"{bug.discussion_link}")
continue
status = pagure_interface.get_issue(issue_id)['status']
if status == "Closed":
app.logger.debug(f"Discussion issue #{issue_id} already closed, skipping")
# Extract issue number from URL
try:
issue_number = int(urlparse(bug.discussion_link).path.rstrip("/").split("/")[-1])
except ValueError:
app.logger.error(
f"Unable to extract issue number from link: '{bug.discussion_link}'"
)
continue
app.logger.debug(f"Closing Forgejo discussion #{issue_number} for bug {bug.bugid}")
try:
status = forgejo_interface.get_issue(issue_number)["state"]
if status == "closed":
app.logger.debug(
f"Discussion issue #{issue_number} already closed, skipping"
)
continue
comment = f"Release F{inactive_release.number} is no longer tracked by "\
f"[BlockerBugs]({app.config['BLOCKERBUGS_URL']}), closing this "\
"ticket."
comment = (
f"Release F{inactive_release.number} is no longer tracked by "
f"[BlockerBugs]({app.config['BLOCKERBUGS_URL']}), closing this "
"ticket."
)
if dry_run:
app.logger.debug(f"[DRY RUN] Closing issue #{issue_id}")
app.logger.debug(f"[DRY RUN] Closing issue #{issue_number}")
else:
app.logger.debug(f"Closing issue #{issue_id}")
app.logger.debug(f"Closing issue #{issue_number}")
# Close first, then submit a comment. While it doesn't look that pretty in
# this order, closing old tickets occurs when a release is over, and a new
# version of BBA might have been deployed by then. We don't want to process
# the old tickets with some new logic. By closing them first, we make sure
# to not process them anymore. #truestory #realworld
pagure_interface.close_issue(issue_id)
pagure_interface.post_comment(issue_id, comment)
except pagure_interface.PagureAPIException as e:
app.logger.error(f'Unable to close Pagure discussion #{issue_id} for bug '
f'{bug.bugid}. Pagure error: {e}')
forgejo_interface.close_issue(issue_number)
forgejo_interface.post_comment(issue_number, comment)
except (forgejo_interface.ForgejoAPIException, ValueError) as e:
app.logger.error(f'Unable to close Forgejo discussion #{issue_number} for bug '
f'{bug.bugid}. Forgejo error: {e}')
all_closed = False
continue
@ -135,4 +272,4 @@ def close_discussions_inactive_releases(dry_run=False):
db.session.add(inactive_release)
db.session.commit()
app.logger.info('Closing discussion tickets in inactive releases done.')
app.logger.info('Closing Forgejo discussion tickets in inactive releases done.')

View file

@ -0,0 +1,572 @@
"""A discussion voting bot for Forgejo"""
import collections
import datetime
import json
import re
from typing import Any, Dict, List, Tuple, Optional, Union
from blockerbugs import app, db
from blockerbugs.util import forgejo_interface
from blockerbugs.models.milestone import Milestone
from blockerbugs.models.bug import Bug
TRACKER_KEYWORDS = [
"betablocker",
"finalblocker",
"betafreezeexception",
"finalfreezeexception",
"0day",
"previousrelease",
]
NICER = {
"betablocker": "BetaBlocker",
"finalblocker": "FinalBlocker",
"betafreezeexception": "BetaFreezeException",
"finalfreezeexception": "FinalFreezeException",
"0day": "0Day",
"previousrelease": "PreviousRelease",
"accepted": "Accepted",
"rejected": "Rejected",
}
VOTES = ("+1", "0", "-1")
TRACKER_RE = r"((beta|final)(blocker|fe|freezeexception)|0day|previousrelease)"
TRACKER_MATCHER = re.compile(TRACKER_RE)
OUTCOME_RE = r"(accepted|rejected)" + TRACKER_RE
OUTCOME_MATCHER = re.compile(OUTCOME_RE)
CLOSED_VOTES_HEADER = "The following votes have been closed:"
# Containers for parsed commands
VoteCommand = collections.namedtuple("VoteCommand", ["tracker", "vote"])
VoteCommand.__doc__ = (
'VoteCommand(tracker, vote): A parsed vote command, e.g. tracker="betablocker", vote="+1"'
)
AgreedCommand = collections.namedtuple(
"AgreedCommand", ["tracker", "outcome", "summary"], defaults=[None]
)
AgreedCommand.__doc__ = (
'AgreedCommand(tracker, outcome, summary): A parsed AGREED command, '
'e.g. tracker="betablocker", outcome="accepted", summary="Breaks boot."'
)
RevoteCommand = collections.namedtuple("RevoteCommand", ["tracker"])
RevoteCommand.__doc__ = (
'RevoteCommand(tracker): A parsed REVOTE command, e.g. tracker="betablocker"'
)
def agreed_revote_parser(line: str) -> List[Union[AgreedCommand, RevoteCommand]]:
"""Parse an AGREED or a REVOTE line. If there's anything violating the
rules for the line (e.g. it doesn't start with the expected command, or
there's some extra text that shouldn't be there), ignore the line contents
and return []. All instances of "fe" get expanded to "freezeexception".
Args:
line: A single line to parse
Returns:
A list of AgreedCommand or RevoteCommand instances.
E.g. for the line 'agreed acceptedbetablocker rejectedfinalfe' this returns:
[AgreedCommand(tracker='betablocker', outcome='accepted'),
AgreedCommand(tracker='finalfreezeexception', outcome='rejected')]
For the line 'revote finalblocker 0day' this returns:
[RevoteCommand(tracker='finalblocker'),
RevoteCommand(tracker='0day')]
"""
# parsing word by word avoids complex and fragile regex syntax
words = line.split()
if not words:
return []
out = []
command = words[0]
for word in words[1:]:
if command == "agreed":
match = OUTCOME_MATCHER.fullmatch(word)
if match:
ac = AgreedCommand(
tracker=expand_fe(match.group(2)),
outcome=match.group(1),
)
out.append(ac)
else:
# this must be the summary now
# FIXME: implement summary parsing
break
elif command == "revote":
match = TRACKER_MATCHER.fullmatch(word)
if match:
rc = RevoteCommand(
tracker=expand_fe(word),
)
out.append(rc)
else:
# this is not a valid REVOTE line
return []
else:
# not an AGREED or REVOTE line
return []
return out
def vote_parser(line: str) -> List[VoteCommand]:
"""Look for votes in a single line. We accept pairs of a vote and a match
of TRACKER_RE, in either order, and nothing else; any other text
invalidates the line. All instances of "fe" get expanded to "freezeexception".
Args:
line: A line which might contain a vote
Returns:
A list of VoteCommand instances.
E.g. for the line 'betafe +1 finalblocker -1' this returns:
[VoteCommand(tracker='betafreezeexception', vote='+1'),
VoteCommand(tracker='finalblocker', vote='-1')]
"""
words = line.split()
# if we have an odd number of words we don't have a clean vote line
if (len(words) % 2) != 0:
return []
out = []
# split list into pairs:
# https://stackoverflow.com/questions/312443
for pair in (words[i : i + 2] for i in range(0, len(words), 2)):
if TRACKER_MATCHER.fullmatch(pair[0]) and pair[1] in VOTES:
vc = VoteCommand(tracker=expand_fe(pair[0]), vote=pair[1])
out.append(vc)
elif pair[0] in VOTES and TRACKER_MATCHER.fullmatch(pair[1]):
vc = VoteCommand(tracker=expand_fe(pair[1]), vote=pair[0])
out.append(vc)
else:
# we found something other than a vote pair, so per
# Kamil's Strict Vote Parsing Regime, we reject the line
return []
return out
def expand_fe(tracker: str) -> str:
"""Expand "fe" into "freezeexception" in tracker names.
Args:
tracker: Tracker name that may contain "fe" abbreviation
Returns:
Expanded tracker name
"""
if tracker == "betafe":
return "betafreezeexception"
if tracker == "finalfe":
return "finalfreezeexception"
return tracker
class ForgejoComment:
"""Adapter for Forgejo comment structure"""
def __init__(
self, comment_data: Dict[str, Any], forgejo: forgejo_interface.ForgejoInterface
) -> None:
"""Initialize from Forgejo comment data
Args:
comment_data: Dict with Forgejo comment data
forgejo: ForgejoInterface instance
"""
self.id: int = comment_data["id"]
# Forgejo uses 'body' instead of 'comment'
self.text: str = comment_data["body"]
# Forgejo uses 'user.login' instead of 'user.name'
self.user: str = comment_data["user"]["login"]
self._forgejo = forgejo
def user_is_admin(self) -> bool:
"""Check if comment user is admin (public member of configured org)
Returns:
bool: True if user is admin
"""
org = app.config["FORGEJO_ADMIN_ORG"]
return self._forgejo.is_org_public_member(self.user, org)
def commands(self) -> List[Union[VoteCommand, AgreedCommand, RevoteCommand]]:
"""Parse voting commands from comment text
Returns:
list: List of VoteCommand/AgreedCommand/RevoteCommand instances
"""
out = []
for line in self.text.lower().split("\n"):
line = line.strip()
if line.startswith("agreed") or line.startswith("revote"):
out.extend(agreed_revote_parser(line))
else:
out.extend(vote_parser(line))
return out
def is_summary_post_of(self, tracker_keyword: str) -> bool:
"""Check if this comment is a bot summary post
Args:
tracker_keyword: Tracker keyword to check for
Returns:
bool: True if this is a summary post for the tracker
"""
is_bot = self.user == app.config["FORGEJO_BOT_USERNAME"]
is_summary = CLOSED_VOTES_HEADER.lower() in self.text.lower()
is_tracker_relevant = tracker_keyword in self.text.lower()
return is_bot and is_summary and is_tracker_relevant
class BugVoteTracker:
"""Count votes specific for a single tracker. Just feed all comments to
this class and it'll reflect the tracker's final state in its variables.
"""
def __init__(self, tracker: str) -> None:
"""Initialize a vote tracker for a specific tracker keyword.
Args:
tracker: One of TRACKER_KEYWORDS (e.g., 'betablocker', 'finalblocker')
"""
self.open = True
#: 'accepted' or 'rejected'
self.outcome: Optional[str] = None
#: one of TRACKER_KEYWORDS
self.tracker = tracker
self.need_summary_post = False
#: user votes in this format:
#: {
#: 'user': {
#: 'vote': '+1'
#: 'comment_id': 21383
#: }
#: }
self.votes: Dict[str, Dict[str, Any]] = {}
def parse_comment(self, comment: ForgejoComment) -> None:
"""Take a comment and parse all commands from it that are relevant to
this particular tracker. Update the instance variables.
Args:
comment: A ForgejoComment instance to parse
"""
if comment.is_summary_post_of(self.tracker):
self.need_summary_post = False
return
# now that we detected summary posts, we can ignore all posts from the bot
if comment.user == app.config["FORGEJO_BOT_USERNAME"]:
return
for command in comment.commands():
if command.tracker != self.tracker:
# not intended for this tracker, ignore
continue
agreed = isinstance(command, AgreedCommand)
revote = isinstance(command, RevoteCommand)
vote = isinstance(command, VoteCommand)
if (agreed or revote) and not comment.user_is_admin():
app.logger.debug(
f"A non-admin user {comment.user} tries to perform "
f"administrative commands in comment {comment.id}, "
"ignoring."
)
continue
if agreed:
self.open = False
self.outcome = command.outcome
self.need_summary_post = True
elif revote:
self.open = True
self.outcome = None
self.need_summary_post = False
self.votes = {}
elif vote:
if not self.open:
continue
self.votes[comment.user] = {"vote": command.vote, "comment_id": comment.id}
else:
msg = f"Unknown command instance: {command}"
app.logger.error(msg)
assert False, msg
def enumerate_votes(self) -> Dict[str, List[Tuple[str, int]]]:
"""Return self.votes in a format:
{ '-1': [('user1', comment_id), ('user2', comment_id)],
'0': [...],
'+1': [...],
}
Returns:
Dict mapping vote values to lists of (user, comment_id) tuples
"""
out = {vote: [] for vote in VOTES}
for user, vote_data in self.votes.items():
out[vote_data["vote"]].append((user, vote_data["comment_id"]))
return out
def link(user: str, comment_id: int) -> str:
"""Create a Markdown snippet containing a link to the user's comment.
Args:
user: Username
comment_id: Comment ID
Returns:
Markdown link string
"""
return f"[{user}](#comment-{comment_id})"
def tracker_summary(tracker_name: str, tracker: BugVoteTracker) -> str:
"""Create a summary of votes for a given tracker in a Markdown format.
Args:
tracker_name: Tracker name (e.g. 'betablocker')
tracker: A BugVoteTracker instance
Returns:
Markdown formatted summary string
"""
out = "* "
outcome = ""
if not tracker.open and tracker.outcome:
outcome = f"**{NICER[tracker.outcome]}** "
out += f"{outcome}{NICER[tracker_name]} "
votes = tracker.enumerate_votes()
pros = len(votes["+1"])
neutrals = len(votes["0"])
cons = len(votes["-1"])
pros_text = f"+{pros}"
if pros > 0:
pros_text = f"**+{pros}**"
cons_text = f"-{cons}"
if cons > 0:
cons_text = f"**-{cons}**"
out += f"({pros_text}, {neutrals}, {cons_text})\n"
return out
def summary(
trackers: Dict[str, BugVoteTracker],
non_voting_users: Optional[Dict[str, int]] = None,
last_comment_id: Optional[int] = None,
header: str = "",
) -> str:
"""Create an overall vote summary in the Markdown format which can be used either in
a ticket description, or as a comment when a vote is closed.
Args:
trackers: Dict as returned from parse_comments_to_trackers
non_voting_users: Users who haven't voted yet as returned by voting_info
last_comment_id: The last comment id that was counted
header: An initial message text
Returns:
Markdown formatted vote summary
"""
md_text = f"{header}\n\n"
for tracker_name, tracker in trackers.items():
if not tracker.votes:
if not tracker.open:
md_text += tracker_summary(tracker_name, tracker)
continue
votes = tracker.enumerate_votes()
md_text += tracker_summary(tracker_name, tracker)
for vote in VOTES:
vote_links = [link(user, comment_id) for (user, comment_id) in votes[vote]]
if len(vote_links) == 0:
continue
md_text += f" * {vote} by {', '.join(vote_links)}\n"
md_text += "\n"
if non_voting_users:
non_voting_list = ", ".join([link(u, c) for (u, c) in non_voting_users.items()])
md_text += f"Commented but haven't voted yet: {non_voting_list}\n\n"
if last_comment_id:
count_time = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d %H:%M")
md_text += (
f"*The votes have been last counted at {count_time} UTC and the last "
f"processed comment was [#comment-{last_comment_id}](#comment-{last_comment_id})*"
)
if not md_text or md_text.isspace():
md_text = "Nobody voted yet."
return md_text
def parse_comments_to_trackers(comments: List[ForgejoComment]) -> Dict[str, BugVoteTracker]:
"""Parse all comments and create vote trackers
Args:
comments: List of ForgejoComment instances
Returns:
dict: {tracker_keyword: BugVoteTracker instance}
"""
trackers = {tracker: BugVoteTracker(tracker) for tracker in TRACKER_KEYWORDS}
for comment in comments:
for tracker in trackers.values():
tracker.parse_comment(comment)
return trackers
def voting_info(
comments: List[ForgejoComment], trackers: Dict[str, BugVoteTracker]
) -> Tuple[Dict[str, int], Optional[int]]:
"""Compute users who haven't voted yet, and the last processed comment
Args:
comments: List of ForgejoComment instances
trackers: Dict of BugVoteTracker instances
Returns:
tuple: (non_voting_users dict, last_comment_id)
"""
# Find users who commented but didn't vote
non_voting_users = {}
all_voters = set()
for tracker in trackers.values():
all_voters.update(tracker.votes.keys())
for comment in comments:
if comment.user != app.config["FORGEJO_BOT_USERNAME"] and comment.user not in all_voters:
non_voting_users[comment.user] = comment.id
last_comment_id = comments[-1].id if comments else None
return non_voting_users, last_comment_id
def bot_loop_detected(comments: List[ForgejoComment]) -> bool:
"""Detect if bot is stuck in a comment loop.
The loop is defined as a continuous stream of more than $threshold comments, all from
the bot, including the last comment in the list. The threshold is defined in config.
Args:
comments: List of ForgejoComment instances
Returns:
bool: True if bot loop detected
"""
threshold = app.config["FORGEJO_BOT_LOOP_THRESHOLD"]
if len(comments) < threshold:
return False
last_comments = comments[-threshold:]
bot_username = app.config["FORGEJO_BOT_USERNAME"]
return all(c.user == bot_username for c in last_comments)
def webhook_handler(issue_number: int) -> None:
"""React to a Forgejo webhook notification that a discussion ticket changed
Args:
issue_number: Forgejo issue number
"""
forgejo = forgejo_interface.ForgejoInterface()
# Get all comments
comment_data = forgejo_interface.get_issue_comments(issue_number)
comments = [ForgejoComment(c, forgejo) for c in comment_data]
# Parse votes from all comments
trackers = parse_comments_to_trackers(comments)
# Get voting info
non_voting_users, last_comment_id = voting_info(comments, trackers)
# Validate last_comment_id
if last_comment_id is None:
app.logger.warning(f"No comments found for Forgejo issue #{issue_number}, skipping")
return
# Convert to int safely
try:
last_comment_id = int(last_comment_id)
except (ValueError, TypeError) as e:
app.logger.warning(f"Invalid last_comment_id for Forgejo issue #{issue_number}: {e}")
return
# Post summary comment if needed (after AGREED/REJECTED)
trackers_without_summary = {n: t for (n, t) in trackers.items() if t.need_summary_post}
if trackers_without_summary:
app.logger.debug("Agreed/Rejected keyword found, posting summary comment")
if bot_loop_detected(comments):
app.logger.error("Bot is probably stuck in loop, not posting summary comment!")
else:
comment = summary(trackers_without_summary, header=CLOSED_VOTES_HEADER)
forgejo_interface.post_comment(issue_number, comment)
# Update issue description with current vote summary
app.logger.debug("Updating issue summary")
vote_summary = summary(trackers, non_voting_users, last_comment_id)
forgejo_interface.update_issue(issue_number, vote_summary)
# Save voting info to database (for all milestones with same bugid)
app.logger.debug("Saving voting info to database")
bug = forgejo_interface.issue_to_bug(issue_number)
if not bug:
app.logger.warning(f"Could not find bug for Forgejo issue #{issue_number}")
return
release = bug.milestone.release
milestones = Milestone.query.filter_by(release=release)
for milestone in milestones:
# bugid is unique in milestone, thus .first()
milestone_bug = Bug.query.filter_by(milestone=milestone, bugid=bug.bugid).first()
if not milestone_bug:
# bug is not present in given milestone
continue
milestone_votes = {}
# go through all trackers, e.g. bug can be proposed as beta blocker,
# but can receive final blocker votes, we want to see it in the meeting format
for tracker_name, tracker in trackers.items():
votes = tracker.enumerate_votes()
# drop comment id
milestone_votes[tracker_name] = {
"-1": [person_vote[0] for person_vote in votes["-1"]],
"0": [person_vote[0] for person_vote in votes["0"]],
"+1": [person_vote[0] for person_vote in votes["+1"]],
}
milestone_bug.votes = json.dumps(milestone_votes)
db.session.add(milestone_bug)
db.session.commit()

View file

@ -0,0 +1,453 @@
"""Forgejo API interactions using pyforgejo"""
from string import Template
from typing import Sequence, Optional, Dict, List, Any
import httpx
from pyforgejo import PyforgejoApi, Issue
from blockerbugs import app
from blockerbugs.models.bug import Bug
class ForgejoAPIException(Exception):
"""
Exception raised when Forgejo API operations fail.
This exception is raised by ForgejoInterface methods when API calls to the
Forgejo instance fail due to network errors, authentication issues, invalid
requests, or other API-related problems.
"""
class BBValueError(ValueError):
"""
Exception raised when parameter validation fails.
This exception is raised by ForgejoInterface methods when provided
parameters are missing, have incorrect type, or have invalid values.
"""
class ForgejoInterface:
"""Wrapper around pyforgejo client"""
def __init__(self) -> None:
"""Initialize Forgejo API client"""
base_url = app.config.get("FORGEJO_API")
api_key = app.config.get("FORGEJO_BOT_ACCESS_TOKEN")
forgejo_repo = app.config.get("FORGEJO_REPO")
if not base_url or not isinstance(base_url, str) or not base_url.strip():
raise BBValueError(
f"FORGEJO_API value invalid, got: {base_url}, type {type(base_url).__name__}"
)
if not api_key or not isinstance(api_key, str) or not api_key.strip():
raise BBValueError(
f"FORGEJO_BOT_ACCESS_TOKEN value invalid, got: {api_key}, type {type(api_key).__name__}"
)
if not forgejo_repo or not isinstance(forgejo_repo, str):
raise BBValueError(
f"FORGEJO_REPO value invalid, got: {forgejo_repo}, type {type(forgejo_repo).__name__}"
)
repo_parts: List[str] = forgejo_repo.split("/")
if len(repo_parts) != 2 or not repo_parts[0] or not repo_parts[1]:
raise BBValueError(
f"FORGEJO_REPO value invalid, got: {forgejo_repo}, type {type(forgejo_repo).__name__}"
)
self.client: PyforgejoApi = PyforgejoApi(base_url=base_url, api_key=api_key)
self.owner, self.repo = repo_parts
def create_issue(self, title: str, content: str, labels: Optional[Sequence[int]] = None) -> str:
"""Create issue and return issue number and URL
Args:
title: Issue title
content: Issue body content
labels: Optional list of label IDs
Returns:
str: Full URL to the created issue
Raises:
BBValueError: If parameters are invalid
ForgejoAPIException: If issue creation fails
"""
if not title or not title.strip():
raise BBValueError("Issue title cannot be empty")
if not content or not content.strip():
raise BBValueError("Issue content cannot be empty")
try:
app.logger.debug(f"Creating Forgejo issue in {self.owner}/{self.repo}")
result: Issue = self.client.issue.create_issue(
owner=self.owner,
repo=self.repo,
title=title,
body=content,
labels=labels,
)
issue_number = result.number
issue_url = f"{app.config['FORGEJO_URL']}{self.owner}/{self.repo}/issues/{issue_number}"
app.logger.debug(f"Created Forgejo issue: {issue_url}")
return issue_url
except Exception as e:
raise ForgejoAPIException(f"Unable to create issue: {e}") from e
def get_issue(self, issue_number: int) -> Dict[str, Any]:
"""Get issue details
Args:
issue_number: Issue number (index)
Returns:
dict: Issue data
Raises:
BBValueError: If issue_number is invalid
ForgejoAPIException: If issue retrieval fails
"""
if issue_number <= 0:
raise BBValueError(f"Issue number must be positive, got: {issue_number}")
try:
app.logger.debug(f"GET Forgejo issue {self.owner}/{self.repo}#{issue_number}")
result: Issue = self.client.issue.get_issue(
owner=self.owner,
repo=self.repo,
index=issue_number,
)
return {
"id": result.id,
"number": result.number,
"title": result.title,
"body": result.body,
"state": result.state,
"user": {"login": result.user.login if result.user else None},
"comments": None, # Not included by default, use get_issue_comments()
}
except Exception as e:
raise ForgejoAPIException(f"Unable to get issue: {e}") from e
def update_issue(
self, issue_number: int, title: Optional[str] = None, body: Optional[str] = None
) -> None:
"""Update issue title and/or body
Args:
issue_number: Issue number (index)
title: New title (optional)
body: New body content (optional)
Raises:
BBValueError: If parameters are invalid
ForgejoAPIException: If issue update fails
"""
if issue_number <= 0:
raise BBValueError(f"Issue number must be positive, got: {issue_number}")
if title is None and body is None:
raise BBValueError("At least one of title or body must be provided")
try:
app.logger.debug(f"Updating Forgejo issue {self.owner}/{self.repo}#{issue_number}")
self.client.issue.edit_issue(
owner=self.owner,
repo=self.repo,
index=issue_number,
title=title,
body=body,
)
except Exception as e:
raise ForgejoAPIException(f"Unable to update issue: {e}") from e
def close_issue(self, issue_number: int) -> None:
"""Close an issue
Args:
issue_number: Issue number (index)
Raises:
BBValueError: If issue_number is invalid
ForgejoAPIException: If issue closing fails
"""
if issue_number <= 0:
raise BBValueError(f"Issue number must be positive, got: {issue_number}")
try:
app.logger.debug(f"Closing Forgejo issue {self.owner}/{self.repo}#{issue_number}")
self.client.issue.edit_issue(
owner=self.owner,
repo=self.repo,
index=issue_number,
state="closed",
)
except Exception as e:
raise ForgejoAPIException(f"Unable to close issue: {e}") from e
def post_comment(self, issue_number: int, comment: str) -> None:
"""Post a comment to an issue
Args:
issue_number: Issue number (index)
comment: Comment text
Raises:
BBValueError: If parameters are invalid
ForgejoAPIException: If comment posting fails
"""
if issue_number <= 0:
raise BBValueError(f"Issue number must be positive, got: {issue_number}")
if not comment or not comment.strip():
raise BBValueError("Comment text cannot be empty")
try:
app.logger.debug(
f"Posting comment to Forgejo issue {self.owner}/{self.repo}#{issue_number}"
)
self.client.issue.create_comment(
owner=self.owner,
repo=self.repo,
index=issue_number,
body=comment,
)
except Exception as e:
raise ForgejoAPIException(f"Unable to post comment: {e}") from e
def get_issue_comments(self, issue_number: int) -> List[Dict[str, Any]]:
"""Get all comments for an issue
Args:
issue_number: Issue number (index)
Returns:
list: List of comment dicts
Raises:
BBValueError: If issue_number is invalid
ForgejoAPIException: If comment retrieval fails
"""
if issue_number <= 0:
raise BBValueError(f"Issue number must be positive, got: {issue_number}")
try:
app.logger.debug(
f"Getting comments for Forgejo issue {self.owner}/{self.repo}#{issue_number}"
)
comments = self.client.issue.get_comments(
owner=self.owner,
repo=self.repo,
index=issue_number,
)
return [
{
"id": comment.id,
"body": comment.body,
"user": {"login": comment.user.login if comment.user else None},
}
for comment in comments
]
except Exception as e:
raise ForgejoAPIException(f"Unable to get comments: {e}") from e
def is_org_public_member(self, username: str, org: str) -> bool:
"""Check if user is a public member of an organization.
Makes an unauthenticated request to the Forgejo public members API.
This avoids the ``read:organization`` token scope requirement that
the authenticated endpoint enforces.
Args:
username: Username to check
org: Organization name
Returns:
bool: True if user is a public org member
Raises:
BBValueError: If parameters are invalid
"""
if not username or not username.strip():
raise BBValueError("Username cannot be empty")
if not org or not org.strip():
raise BBValueError("Organization name cannot be empty")
try:
app.logger.debug(f"Checking if {username} is public member of {org}")
base_url = app.config.get("FORGEJO_API", "").rstrip("/")
url = f"{base_url}/orgs/{org}/public_members/{username}"
response = httpx.get(url, timeout=10)
# 204 = is a public member, 404 = not a public member
return response.status_code == 204
except Exception as e: # pylint: disable=broad-exception-caught
# If membership check fails, log but don't raise
# This allows the bot to continue working even if the API is unavailable
app.logger.warning(
f"Unable to check public membership for {username} in {org}: {e}"
)
return False
def create_bug_discussion(bug: Bug) -> str:
"""Create a Forgejo discussion issue for a bug
Args:
bug: Bug model instance
Returns:
str: URL of created issue
"""
title = render_discussion_template(app.config["FORGEJO_DISCUSSION_TITLE"], bug)
content = render_discussion_template(app.config["FORGEJO_DISCUSSION_CONTENT"], bug)
forgejo = ForgejoInterface()
return forgejo.create_issue(title, content)
def issue_to_bug(issue_number: int) -> Optional[Bug]:
"""Find bug by Forgejo issue number
Args:
issue_number: Forgejo issue number
Returns:
Bug: Bug model instance or None
"""
forgejo = ForgejoInterface()
discussion_link = (
f"{app.config['FORGEJO_URL']}{forgejo.owner}/{forgejo.repo}/issues/{issue_number}"
)
return Bug.query.filter_by(discussion_link=discussion_link).first()
def update_issue(issue_number: int, vote_summary: str) -> None:
"""Update Forgejo issue with new vote summary
Args:
issue_number: Forgejo issue number
vote_summary: Markdown-formatted vote summary
Raises:
ForgejoAPIException: If no bug found for issue number or if update fails
"""
bug = issue_to_bug(issue_number)
if bug is None:
app.logger.error(f"No bug found for Forgejo issue #{issue_number}")
raise ForgejoAPIException(f"No bug found for issue number {issue_number}")
title = render_discussion_template(app.config["FORGEJO_DISCUSSION_TITLE"], bug)
content = render_discussion_template(
app.config["FORGEJO_DISCUSSION_CONTENT"], bug, vote_summary
)
forgejo = ForgejoInterface()
forgejo.update_issue(issue_number, title=title, body=content)
def close_issue(issue_number: int) -> None:
"""Close a Forgejo issue
Args:
issue_number: Forgejo issue number
"""
forgejo = ForgejoInterface()
forgejo.close_issue(issue_number)
def get_issue(issue_number: int) -> Dict[str, Any]:
"""Get Forgejo issue details
Args:
issue_number: Forgejo issue number
Returns:
dict: Issue data
"""
forgejo = ForgejoInterface()
return forgejo.get_issue(issue_number)
def get_issue_comments(issue_number: int) -> List[Dict[str, Any]]:
"""Get all comments for a Forgejo issue
Args:
issue_number: Forgejo issue number
Returns:
list: List of comment dicts
"""
forgejo = ForgejoInterface()
return forgejo.get_issue_comments(issue_number)
def post_comment(issue_number: int, comment: str) -> None:
"""Post a comment to a Forgejo issue
Args:
issue_number: Forgejo issue number
comment: Comment text
"""
forgejo = ForgejoInterface()
forgejo.post_comment(issue_number, comment)
def render_discussion_template(
template: str, bug: Bug, vote_summary: str = "Nobody voted yet."
) -> str:
"""Render a discussion template with bug data
Args:
template: Template string with $variables
bug: Bug model instance
vote_summary: Vote summary text (default: 'Nobody voted yet.')
Returns:
str: Rendered template
Raises:
BBValueError: If template or bug is invalid
"""
if not template or not template.strip():
raise BBValueError("Template cannot be empty")
if bug is None:
raise BBValueError("Bug cannot be None")
rendered = Template(template).substitute(
blockerbugs_url=app.config["BLOCKERBUGS_URL"],
bug_img=f"{app.config['BLOCKERBUGS_API']}bugimg/{bug.bugid}",
bug_url=bug.url,
bugid=bug.bugid,
component=bug.component,
forgejo_url=app.config["FORGEJO_URL"],
forgejo_repo=app.config["FORGEJO_REPO"],
summary=bug.summary,
vote_summary=vote_summary,
)
return rendered

View file

@ -1,488 +0,0 @@
"""A discussion voting bot for Pagure"""
import datetime
import re
import collections
import json
from blockerbugs import app, db
from blockerbugs.util import pagure_interface
from blockerbugs.models.milestone import Milestone
from blockerbugs.models.bug import Bug
TRACKER_KEYWORDS = [
'betablocker',
'finalblocker',
'betafreezeexception',
'finalfreezeexception',
'0day',
'previousrelease'
]
NICER = {
'betablocker': 'BetaBlocker',
'finalblocker': 'FinalBlocker',
'betafreezeexception': 'BetaFreezeException',
'finalfreezeexception': 'FinalFreezeException',
'0day': '0Day',
'previousrelease': 'PreviousRelease',
'accepted': 'Accepted',
'rejected': 'Rejected'
}
VOTES = ("+1", "0", "-1")
TRACKER_RE = r'((beta|final)(blocker|fe|freezeexception)|0day|previousrelease)'
TRACKER_MATCHER = re.compile(TRACKER_RE)
OUTCOME_RE = r'(accepted|rejected)' + TRACKER_RE
OUTCOME_MATCHER = re.compile(OUTCOME_RE)
CLOSED_VOTES_HEADER = "The following votes have been closed:"
# Containers for parsed commands
VoteCommand = collections.namedtuple('VoteCommand', ['tracker', 'vote'])
VoteCommand.__doc__ += (
': A parsed vote command, e.g. tracker="betablocker", vote="+1"')
AgreedCommand = collections.namedtuple(
'AgreedCommand', ['tracker', 'outcome', 'summary'], defaults=[None])
AgreedCommand.__doc__ += (
': A parsed AGREED command, e.g. tracker="betablocker", '
'outcome="accepted", summary="Breaks boot."')
RevoteCommand = collections.namedtuple('RevoteCommand', ['tracker'])
RevoteCommand.__doc__ += ': A parsed REVOTE command, e.g. tracker="betablocker"'
def agreed_revote_parser(line):
"""Parse an AGREED or a REVOTE line. If there's anything violating the
rules for the line (e.g. it doesn't start with the expected command, or
there's some extra text that shouldn't be there), ignore the line contents
and return []. All instances of "fe" get expanded to "freezeexception".
:param str line: a single line
:return: A list of :class:`AgreedCommand` or :class:`RevoteCommand` instances.
E.g. for the line 'agreed acceptedbetablocker rejectedfinalfe' this returns:
[AgreedCommand(tracker='betablocker', outcome='accepted'),
AgreedCommand(tracker='finalfreezeexception', outcome='rejected')]
For the line 'revote finalblocker 0day' this returns:
[RevoteCommand(tracker='finalblocker'),
RevoteCommand(tracker='0day')]
"""
# parsing word by word avoids complex and fragile regex syntax
words = line.split()
if len(words) <= 0:
return []
out = []
command = words[0]
for word in words[1:]:
if command == 'agreed':
match = OUTCOME_MATCHER.fullmatch(word)
if (match):
ac = AgreedCommand(
tracker=expand_fe(match.group(2)),
outcome=match.group(1),
)
out.append(ac)
else:
# this must be the summary now
# FIXME: implement summary parsing
break
elif command == 'revote':
match = TRACKER_MATCHER.fullmatch(word)
if (match):
rc = RevoteCommand(
tracker=expand_fe(word),
)
out.append(rc)
else:
# this is not a valid REVOTE line
return []
else:
# not an AGREED or REVOTE line
return []
return out
def vote_parser(line):
"""Look for votes in a single line. We accept pairs of a vote and a match
of TRACKER_RE, in either order, and nothing else; any other text
invalidates the line. All instances of "fe" get expanded to "freezeexception".
:param str line: a line which might contain a vote
:return: A list of :class:`VoteCommand` instances.
E.g. for the line 'betafe +1 finalblocker -1' this returns:
[VoteCommand(tracker='betafreezeexception', vote='+1'),
VoteCommand(tracker='finalblocker', vote='-1')]
"""
words = line.split()
# if we have an odd number of words we don't have a clean vote line
if (len(words) % 2) != 0:
return []
out = []
# split list into pairs:
# https://stackoverflow.com/questions/312443
for pair in (words[i:i + 2] for i in range(0, len(words), 2)):
if TRACKER_MATCHER.fullmatch(pair[0]) and pair[1] in VOTES:
vc = VoteCommand(
tracker=expand_fe(pair[0]),
vote=pair[1]
)
out.append(vc)
elif pair[0] in VOTES and TRACKER_MATCHER.fullmatch(pair[1]):
vc = VoteCommand(
tracker=expand_fe(pair[1]),
vote=pair[0]
)
out.append(vc)
else:
# we found something other than a vote pair, so per
# Kamil's Strict Vote Parsing Regime, we reject the line
return []
return out
def expand_fe(tracker):
'''Expand "fe" into "freezeexception" in tracker names.'''
if tracker == 'betafe':
return 'betafreezeexception'
elif tracker == 'finalfe':
return 'finalfreezeexception'
else:
return tracker
def bot_loop_detected(comments):
'''Detect a loop of bot comments in the list of comments. The loop is
defined as a continuous stream of more than $threshold comments, all from
the bot, including the last comment in the list. The threshold is defined
in config.
:param comments: a list of Comment instances
:return: bool whether there is a loop
'''
is_bot_comment = [comment.user == app.config['PAGURE_BOT_USERNAME'] for comment in comments]
threshold = app.config['PAGURE_BOT_LOOP_THRESHOLD']
if len(is_bot_comment) < threshold:
return False
return all(is_bot_comment[-threshold:])
class Comment():
'''This represents a single comment from the voting ticket.
'''
def __init__(self, raw_comment):
self.text = raw_comment['comment'].replace('\r', '')
self.user = raw_comment['user']['name']
#: Pagure's comment id (int)
self.id = raw_comment['id']
def __repr__(self):
return "%s (%s): %s" % (self.user, self.id, self.text)
def user_is_admin(self):
# FIXME use config for group name
# FIXME it would be good to save/cache the data
return self.user in pagure_interface.get_group('fedora-qa')['members']
def commands(self):
'''Search for commands in this comment, purge anything unrelated and
return it parsed.
:return: a list of :class:`VoteCommand`, :class:`AgreedCommand`, and
:class:`RevoteCommand` instances, e.g.:
[VoteCommand(tracker='betablocker', vote='+1'),
AgreedCommand(tracker='betablocker', outcome='accepted'),
RevoteCommand(tracker='betablocker'),
VoteCommand(tracker='betablocker', vote='-1')]
'''
out = []
for line in self.text.lower().split('\n'):
line = line.strip()
if line.startswith('agreed') or line.startswith('revote'):
out.extend(agreed_revote_parser(line))
else:
out.extend(vote_parser(line))
return out
def is_summary_post_of(self, tracker_keyword):
'''Return True/False whether this particular post is a summary of
voting results for a particular tracker.
'''
is_bot = (self.user == app.config['PAGURE_BOT_USERNAME'])
is_summary = (CLOSED_VOTES_HEADER.lower() in self.text.lower())
is_tracker_relevant = (tracker_keyword in self.text.lower())
return is_bot and is_summary and is_tracker_relevant
class BugVoteTracker():
'''Count votes specific for a single tracker. Just feed all comments to
this class and it'll reflect the tracker's final state in its variables.
'''
def __init__(self, tracker):
self.open = True
#: 'accepted' or 'rejected'
self.outcome = None
#: one of TRACKER_KEYWORDS
self.tracker = tracker
self.need_summary_post = False
#: user votes in this format:
#: {
#: 'user': {
#: 'vote': '+1'
#: 'comment_id': 21383
#: }
#: }
self.votes = {}
def parse_comment(self, comment):
'''Take a comment and parse all commands from it that are relevant to
this particular tracker. Update the instance variables.'''
if comment.is_summary_post_of(self.tracker):
self.need_summary_post = False
return
# now that we detected summary posts, we can ignore all posts from the bot
if comment.user == app.config['PAGURE_BOT_USERNAME']:
return
for command in comment.commands():
if command.tracker != self.tracker:
# not intended for this tracker, ignore
continue
agreed = isinstance(command, AgreedCommand)
revote = isinstance(command, RevoteCommand)
vote = isinstance(command, VoteCommand)
if (agreed or revote) and not comment.user_is_admin():
app.logger.debug(
f'A non-admin user {comment.user} tries to perform '
f'administrative commands in comment {comment.id}, '
'ignoring.')
continue
if agreed:
self.open = False
self.outcome = command.outcome
self.need_summary_post = True
elif revote:
self.open = True
self.outcome = None
self.need_summary_post = False
self.votes = {}
elif vote:
if not self.open:
continue
self.votes[comment.user] = {
'vote': command.vote,
'comment_id': comment.id
}
else:
msg = f'Unknown command instance: {command}'
app.logger.error(msg)
assert False, msg
def enumerate_votes(self):
'''Return self.votes in a format:
{ '-1': [('user1', comment_id), ('user2', comment_id)],
'0': [...],
'+1': [...],
}
'''
out = {vote: [] for vote in VOTES}
for user, vote in self.votes.items():
out[vote['vote']].append((user, vote['comment_id']))
return out
def link(user, comment_id):
'''Create a Markdown snippet containing a link to the user's comment.
'''
return "[%s](#comment-%s)" % (user, comment_id)
def tracker_summary(tracker_name, tracker):
'''Create a summary of votes for a given tracker in a Markdown format.
:param str tracker_name: e.g. 'BetaBlocker'
:param tracker: an instance of BugVoteTracker
'''
out = "* "
outcome = ""
if not tracker.open:
outcome = "**%s** " % NICER[tracker.outcome]
out += "%s%s " % (outcome, NICER[tracker_name])
votes = tracker.enumerate_votes()
pros = len(votes['+1'])
neutrals = len(votes['0'])
cons = len(votes['-1'])
pros_text = "+%s" % pros
if pros > 0:
pros_text = "**+%s**" % pros
cons_text = "-%s" % cons
if cons > 0:
cons_text = "**-%s**" % cons
out += "(%s, %s, %s)\n" % (pros_text, neutrals, cons_text)
return out
def summary(trackers: dict[str, BugVoteTracker], non_voting_users: dict[str, int] = None,
last_comment_id: int = None, header: str = "") -> str:
'''Create an overall vote summary in the Markdown format which can be used either in
a ticket description, or as a comment when a vote is closed.
:param trackers: a dict as returned from :func:`parse_comments_to_trackers`
:param non_voting_users: users who haven't voted yet as returned by :func:`voting_info`
:param int last_comment_id: the last comment id that was counted
:param str header: an initial message text
'''
md_text = "%s\n\n" % header
for tracker_name, tracker in trackers.items():
if not tracker.votes:
if not tracker.open:
md_text += tracker_summary(tracker_name, tracker)
continue
votes = tracker.enumerate_votes()
md_text += tracker_summary(tracker_name, tracker)
for vote in VOTES:
votes[vote] = [link(user, comment_id) for (user, comment_id) in votes[vote]]
if len(votes[vote]) == 0:
continue
md_text += " * %s by %s\n" % (vote, ', '.join(votes[vote]))
md_text += '\n'
if non_voting_users:
md_text += "Commented but haven't voted yet: %s\n\n" % ', '.join(
[link(u, c) for (u, c) in non_voting_users.items()])
if last_comment_id:
time = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d %H:%M")
md_text += ("*The votes have been last counted at %s UTC and the last "
"processed comment was [#comment-%s](#comment-%s)*" % (
time, last_comment_id, last_comment_id))
if not md_text or md_text.isspace():
md_text = 'Nobody voted yet.'
return md_text
def parse_comments_to_trackers(comments):
'''Create BugVoteTracker for each tracker keyword supported, and then feed
all comments to each tracker to fully update them.
:param comments: a list of Comment instances
:return: A dict in format:
{'betablocker': BugVoteTracker,
'betafreezeexception': BugVoteTracker,
...
}
'''
trackers = {tracker: BugVoteTracker(tracker) for tracker in TRACKER_KEYWORDS}
for comment in comments:
for tracker in trackers.values():
tracker.parse_comment(comment)
return trackers
def voting_info(comments, trackers):
'''Compute users who haven't voted yet, and the last processed comment
:param comments: a list of Comment instances
:param trackers: a dict as returned from :func:`parse_comments_to_trackers`
:return: a tuple of (dict of non_voting_users, last_comment_id), e.g.:
({'user1': 1234,
'user2': 1235
},
1238)
'''
last_comment_id = 0
non_voting_users = {}
voting_users = set()
for comment in comments:
last_comment_id = comment.id
if comment.user != app.config['PAGURE_BOT_USERNAME']:
non_voting_users[comment.user] = comment.id
for tracker in trackers.values():
voting_users |= tracker.votes.keys()
for user in voting_users:
non_voting_users.pop(user)
return non_voting_users, last_comment_id
def webhook_handler(issue_id):
'''React to a Pagure webhook notification that a discussion ticket changed.
'''
comments = [Comment(c) for c in pagure_interface.get_issue_comments(issue_id)]
trackers = parse_comments_to_trackers(comments)
non_voting_users, last_comment_id = voting_info(comments, trackers)
trackers_without_summary = {n: t for (n, t) in trackers.items() if t.need_summary_post}
if trackers_without_summary:
app.logger.debug('Agreed/Rejected keyword found, posting summary comment')
if bot_loop_detected(comments):
app.logger.error('Bot is probably stuck in loop, not posting summary comment!')
else:
comment = summary(trackers_without_summary, header=CLOSED_VOTES_HEADER)
pagure_interface.post_comment(issue_id, comment)
app.logger.debug('Updating issue summary')
vote_summary = summary(trackers, non_voting_users, last_comment_id)
pagure_interface.update_issue(issue_id, vote_summary)
app.logger.debug('Saving voting info to database')
bug = pagure_interface.issue_to_bug(issue_id)
release = bug.milestone.release
milestones = Milestone.query.filter_by(release=release)
for milestone in milestones:
# bugid is unique in milestone, thus .first()
milestone_bug = Bug.query.filter_by(milestone=milestone, bugid=bug.bugid).first()
if not milestone_bug:
# bug is not present in given milestone
continue
milestone_votes = {}
# go through all trackers, e.g. bug can be proposed as beta blocker,
# but can receive final blocker votes, we want to see it in the meeting format
for tracker_name, tracker in trackers.items():
votes = tracker.enumerate_votes()
# drop comment id
milestone_votes[tracker_name] = {
"-1": [person_vote[0] for person_vote in votes["-1"]],
"0": [person_vote[0] for person_vote in votes["0"]],
"+1": [person_vote[0] for person_vote in votes["+1"]],
}
milestone_bug.votes = json.dumps(milestone_votes)
db.session.add(milestone_bug)
db.session.commit()

View file

@ -1,162 +0,0 @@
"""Pagure API interactions"""
import requests
from string import Template
from blockerbugs import app
from blockerbugs.models.bug import Bug
class PagureAPIException(Exception):
pass
def create_bug_discussion(bug):
title = render_discussion_template(app.config['PAGURE_DISCUSSION_TITLE'], bug)
content = render_discussion_template(app.config['PAGURE_DISCUSSION_CONTENT'], bug)
return create_issue(title, content)
def issue_to_bug(issue_id):
discussion_link = app.config['PAGURE_URL'] + app.config['PAGURE_REPO'] + "/issue/%s" % issue_id
return Bug.query.filter_by(discussion_link=discussion_link).first()
def update_issue(issue_id, vote_summary):
update_issue_url = app.config['PAGURE_API'] + app.config['PAGURE_REPO'] + '/issue/%s' % issue_id
bug = issue_to_bug(issue_id)
title = render_discussion_template(app.config['PAGURE_DISCUSSION_TITLE'], bug)
content = render_discussion_template(app.config['PAGURE_DISCUSSION_CONTENT'],
bug, vote_summary)
data = {
'title': title,
'issue_content': content,
}
app.logger.debug('POST request to %s' % update_issue_url)
resp = requests.post(update_issue_url,
headers={
'Authorization': 'token %s' % app.config['PAGURE_REPO_TOKEN']
},
data=data)
if resp.status_code != 200:
raise PagureAPIException('Unable to create issue, response code: %d, '
'response text:\n%s' % (resp.status_code, resp.text))
def create_issue(title, content, tag=None):
create_issue_url = app.config['PAGURE_API'] + app.config['PAGURE_REPO'] + '/new_issue'
data = {
'title': title,
'issue_content': content,
}
if tag:
data['tag'] = tag
app.logger.debug('POST request to %s' % create_issue_url)
resp = requests.post(create_issue_url,
headers={
'Authorization': 'token %s' % app.config['PAGURE_REPO_TOKEN']
},
data=data)
if resp.status_code != 200:
raise PagureAPIException('Unable to create issue, response code: %d, '
'response text:\n%s' % (resp.status_code, resp.text))
issue_id = resp.json()['issue']['id']
issue_url = app.config['PAGURE_URL'] + app.config['PAGURE_REPO'] + "/issue/%s" % issue_id
app.logger.debug('Created pagure issue: %s' % issue_url)
return issue_url
def close_issue(issue_id):
change_issue_status(issue_id, 'Closed')
def change_issue_status(issue_id, status, close_status=None):
change_issue_status_url = app.config['PAGURE_API'] + \
app.config['PAGURE_REPO'] + '/issue/%s/status' % issue_id
data = {
'status': status,
}
if close_status:
data['close_status'] = close_status
resp = requests.post(change_issue_status_url,
headers={
'Authorization': 'token %s' % app.config['PAGURE_REPO_TOKEN']
},
data=data)
if resp.status_code != 200:
raise PagureAPIException('Unable to change issue status, response code: %d, '
'response text:\n%s' % (resp.status_code, resp.text))
def get_issue(issue_id):
get_issue_url = app.config['PAGURE_API'] + app.config['PAGURE_REPO'] + '/issue/%s' % issue_id
resp = requests.get(get_issue_url)
app.logger.debug('GET request to %s' % get_issue_url)
if resp.status_code != 200:
raise PagureAPIException('Unable to get issue, response code: %d, '
'response text:\n%s' % (resp.status_code, resp.text))
return resp.json()
def get_issue_comments(issue_id):
issue = get_issue(issue_id)
return issue['comments']
def post_comment(issue_id, comment):
post_comment_url = app.config['PAGURE_API'] + app.config['PAGURE_REPO'] + '/issue/%s/comment' % issue_id
data = {
'comment': comment
}
app.logger.debug('POST request to %s' % post_comment_url)
resp = requests.post(post_comment_url,
headers={
'Authorization': 'token %s' % app.config['PAGURE_REPO_TOKEN']
},
data=data)
if resp.status_code != 200:
raise PagureAPIException('Unable to create issue, response code: %d, '
'response text:\n%s' % (resp.status_code, resp.text))
def get_group(group):
get_group_url = app.config['PAGURE_API'] + '/group/%s' % group
resp = requests.get(get_group_url)
app.logger.debug('GET request to %s' % get_group_url)
if resp.status_code != 200:
raise PagureAPIException('Unable to get group info, response code: %d, '
'response text:\n%s' % (resp.status_code, resp.text))
return resp.json()
def render_discussion_template(template, bug, vote_summary='Nobody voted yet.'):
rendered = Template(template).substitute(
blockerbugs_url=app.config['BLOCKERBUGS_URL'],
bug_img=f"{app.config['BLOCKERBUGS_API']}bugimg/{bug.bugid}",
bug_url=bug.url,
bugid=bug.bugid,
component=bug.component,
pagure_url=app.config['PAGURE_URL'],
pagure_repo=app.config['PAGURE_REPO'],
summary=bug.summary,
vote_summary=vote_summary,
)
return rendered

View file

@ -13,12 +13,44 @@ SYSLOG_LOGGING = False
STREAM_LOGGING = True
FEDMENU_URL = "https://apps.fedoraproject.org/fedmenu/"
FEDMENU_DATA_URL = "https://apps.fedoraproject.org/js/data.js"
PAGURE_BOT_ENABLED = False
PAGURE_URL = "https://stg.pagure.io/"
PAGURE_API = "https://stg.pagure.io/api/0/"
PAGURE_REPO = "fedora-qa/blocker-review"
PAGURE_REPO_TOKEN = "YOUR SECRET API TOKEN FROM PROJECT SETTINGS"
PAGURE_BOT_USERNAME = 'blockerbot'
# Forgejo is used to create voting tickets
FORGEJO_URL = "https://forge.stg.fedoraproject.org/"
FORGEJO_API = "https://forge.stg.fedoraproject.org/api/v1/"
FORGEJO_REPO = "quality/blocker-review"
# Forgejo bot is used to automatically respond to vote commands in voting tickets
# If you disable the bot, it will not respond to voting commands. The initial Forgejo voting tickets
# will still get created (once you call `blockerbugs sync-discussions`), that is not related to
# the bot.
FORGEJO_BOT_ENABLED = True
# This configured bot account must have write access to FORGEJO_REPO.
# Configure in: Repo -> Settings -> Collaborators (through Collaborators or Teams).
FORGEJO_BOT_USERNAME = 'blockerbot'
# API token to allow bot creating and updating issues
# Bot is granted access to Forgejo API by using this access token
# Define in: User -> Settings -> Applications -> Access tokens
# Required token permissions: issue - Read and write
FORGEJO_BOT_ACCESS_TOKEN = "BOT'S FORGEJO API ACCESS TOKEN"
# Repo webhook to signal repo events back to blockerbugs app
# Define in: Repo -> Settings -> Webhooks
# Webhook params:
# - Target URL: https://qa.stg.fedoraproject.org/blockerbugs/api/v0/webhook/forgejo
# - HTTP method: POST
# - POST content type: application/json
# - Secret: define your own and add it to this env var
# - Trigger on: Custom events - all issue events
FORGEJO_REPO_WEBHOOK_SECRET = "YOUR WEBHOOK SECRET"
# Forgejo organization owning FORGEJO_REPO
# Public members of this org are allowed to use bot admin commands (AGREED/REVOTE)
# in FORGEJO_REPO discussion issues.
# NOTE: Members must have their org membership set to PUBLIC in Forgejo.
FORGEJO_ADMIN_ORG = "quality"
SHOW_DB_URI = False
BEHIND_PROXY = False
BLOCKERBUGS_API = "https://qa.fedoraproject.org/blockerbugs/api/v0/"

View file

@ -0,0 +1,89 @@
# BlockerBugs Application - High-Level Workflow
## Application Purpose
BlockerBugs tracks and manages Fedora blocker bugs and freeze exception bugs across different release
milestones (beta, final, etc.). It integrates with Bugzilla, Bodhi, and Forgejo to provide a centralized view and voting system.
## Main Workflow
### 1. Data Synchronization (Scheduled via CLI/Cron)
Bug Sync from Bugzilla:
- CLI command: `blockerbugs sync-bugs` (or `./run cli sync-bugs` during development)
- Queries Bugzilla tracker tickets for each active milestone
- Parses bug whiteboard for status keywords (e.g. `AcceptedBlocker`, `RejectedBlocker`,
`AcceptedFreezeException`, `RejectedFreezeException`, `Accepted0Day`, `AcceptedPreviousRelease`)
- Updates database with bug status, needinfo flags, and dependencies
- Handles three tracker types: Blockers, Freeze Exceptions, and Prioritized Bugs
Update Sync from Bodhi:
- CLI command: `blockerbugs sync-updates` (or `./run cli sync-updates` during development)
- Queries Bodhi for updates that fix tracked bugs
- Links updates to bugs (many-to-many relationship)
- Tracks update status: pending, testing, stable
Discussion Sync to Forgejo:
- CLI command: `blockerbugs sync-discussions` (or `./run cli sync-discussions` during development)
- Creates Forgejo issue tickets for bugs needing discussion
- Each bug gets a discussion thread for voting
All three syncs can be run together with the `blockerbugs sync` command (or `./run cli sync`).
### 2. Real-Time Voting via Forgejo Webhook
When a comment is added or edited on a Forgejo discussion ticket:
- Forgejo sends a webhook to BlockerBugs
- The comment is parsed for voting commands
- Votes are tracked per tracker type and voter
- The Forgejo issue description is updated with a vote summary
- Voting data is saved to the database
- Admin commands (AGREED, REVOTE) are supported for finalizing or reopening votes
Vote Command Format:
- BetaBlocker +1 / FinalBlocker -1
- BetaFE +1 / FinalFE -1 (shorthand for BetaFreezeException / FinalFreezeException)
- 0Day +1 / PreviousRelease +1
### 3. User Interactions
Proposing a Bug:
- User submits form at `/propose_bug` (requires OIDC login)
- Validates bug exists in Bugzilla and is not closed
- Updates Bugzilla to add the bug to the appropriate tracker
- Immediately syncs the bug to the database (without waiting for scheduled sync)
- Creates a Forgejo discussion ticket for voting
- Redirects to milestone buglist
Viewing Data:
- `/milestone/<num>/<release>/buglist` - Main bug dashboard (accepted, proposed, freeze exceptions)
- `/milestone/<num>/<release>/updates` - Bodhi updates fixing tracked bugs
- `/milestone/<num>/<release>/meeting` - Meeting format with voting information
- `/milestone/<num>/<release>/requests` - Freeze push/compose request format
- AJAX tooltips for bug updates and dependencies
API Access:
- `/api/v0/milestones/<num>/<version>/bugs` - JSON bug list
- `/api/v0/milestones/<num>/<version>/updates` - JSON update list
- `/api/v0/bugimg/<bug_id>` - SVG badge showing bug status across milestones
### Key Architecture Points
- Scheduled CLI commands sync bug and update data periodically from Bugzilla and Bodhi
- Real-time voting via Forgejo webhook for immediate vote processing and tallying
- Milestone-based tracking: the same bug can appear in multiple milestones (beta, final)
- Three-way integration: Bugzilla (bug source), BlockerBugs (tracking), Forgejo (voting)
- Flask-Admin for managing releases and milestones
- OIDC authentication for user actions (proposing bugs)
The app acts as a coordinator between Bugzilla (authoritative bug source),
Bodhi (updates), and Forgejo (discussion/voting), providing a unified view and workflow
for the Fedora QA team to track release-blocking issues.

View file

@ -9,7 +9,7 @@ blockerbugs command [options]
Possible Commands
^^^^^^^^^^^^^^^^^
init_db, add_milestone, add_release, generate_config, sync-updates, sync-bugs, sync, upgrade_db
init_db, add_milestone, add_release, generate_config, sync, sync-bugs, sync-updates, sync-discussions, upgrade_db, recreate-discussion, update-discussion, close-inactive-discussions, create-test-data, remove-test-data
Description
-----------
@ -23,7 +23,8 @@ Commands
init_db
^^^^^^^
Initialize the database using the global configuration
Initialize the database using the global configuration. Use with ``-rm``/``--destructive``
to force database recreation (WARNING: this will erase your database).
add_milestone
^^^^^^^^^^^^^
@ -38,14 +39,14 @@ Adds a Fedora release to the tracker. Required options: -r
generate_config
^^^^^^^^^^^^^^^
Generates a template config file with a SECRET_KEY. Optional options: -d, -z
Generates a template config file with a SECRET_KEY. Optional options: -d
sync
^^^^
Uses the applications config settings and stored information about milestones
and releases to sync information about blocker/FE bugs for currently active
milestones and bodhi updates which are marked as fixing those bugs
Runs all synchronization operations: syncs bug information from Bugzilla,
updates from Bodhi, and discussion tickets to Forgejo for currently active
milestones.
sync-updates
^^^^^^^^^^^^
@ -58,55 +59,124 @@ sync-bugs
Runs sync but does not sync with bodhi, only pulls information from bugzilla
sync-discussions
^^^^^^^^^^^^^^^^
Synchronizes discussion tickets to Forgejo. Creates missing discussion issues
for bugs that need one.
upgrade_db
^^^^^^^^^^
Upgrades the currently configured database to the alembic head - requires a
database user with privelages to change the schema.
minify
^^^^^^
recreate-discussion <Bug ID>
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Minifies (merges and compresses) JavaScript and CSS files. Needs to be run
after each change in JavaScript and/or CSS file.
Re-creates the Forgejo discussion issue for a given bug. Useful if a discussion
issue was accidentally deleted or needs to be regenerated.
update-discussion <Issue Number>
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Manually triggers the webhook handler for a given Forgejo issue number. Useful
for reprocessing votes on a specific discussion issue.
close-inactive-discussions
^^^^^^^^^^^^^^^^^^^^^^^^^^
Closes Forgejo discussion issues for releases that are no longer active (and
haven't been processed yet). Use with ``--dryrun`` to preview changes without
making them.
create-test-data
^^^^^^^^^^^^^^^^
Creates fake test data under release 101. Can be called repeatedly -- always
deletes everything under that release and creates test data anew. WARNING:
never run this if you have actual real data under release 101.
remove-test-data
^^^^^^^^^^^^^^^^
Removes fake test data. Deletes release 101 and everything under it. WARNING:
never run this if you have actual real data under release 101.
Options
-------
Global Options
^^^^^^^^^^^^^^
--debug
"""""""
Enable debug logging. Can be used with any command.
Sync Options
^^^^^^^^^^^^
-f, --full
^^^^^^^^^^
""""""""""
Forces a full sync of bugs and updates instead of just the bugs which have
changed since last sync. Default: False
changed since last sync. Default: False. Used with ``sync``, ``sync-bugs``.
-c, --check
^^^^^^^^^^^
""""""""""""
Force check for missing bugs after sync operation
Force check for missing bugs after sync operation. Used with ``sync``,
``sync-bugs``.
Database Options
^^^^^^^^^^^^^^^^
-rm, --destructive
""""""""""""""""""
Force database recreation. WARNING: this will erase your database. Used with
``init_db``.
Config Options
^^^^^^^^^^^^^^
-d, --dburi
^^^^^^^^^^^
"""""""""""
URI of database to use during config file generation
URI of database to use during config file generation. Used with
``generate_config``.
-r ,--release
^^^^^^^^^^^^^
Release/Milestone Options
^^^^^^^^^^^^^^^^^^^^^^^^^
The release number to use for milestone and release creation.
-r, --release
"""""""""""""
-m --milestone
^^^^^^^^^^^^^^
The release number to use for milestone and release creation. Used with
``add_release``, ``add_milestone``.
The milestone name to use for milestone creation
-m, --milestone
"""""""""""""""
The milestone name to use for milestone creation. Used with ``add_milestone``.
-b, --blocker
^^^^^^^^^^^^^
"""""""""""""
The blocker tracking bugid for a milestone
The blocker tracking bugid for a milestone. Used with ``add_milestone``.
-a, --accepted
^^^^^^^^^^^^^^
""""""""""""""
The FE tracking bugid for a milestone
The FE tracking bugid for a milestone. Used with ``add_milestone``.
Discussion Options
^^^^^^^^^^^^^^^^^^
--dryrun
""""""""
Don't make any actual changes. Used with ``close-inactive-discussions``.

View file

@ -27,26 +27,27 @@ Set up a PostgreSQL database
PostgreSQL should be used both for development and production. You can set it up similarly to how we :doc:`configure production <installation>`, but you'll find a more convenient development setup below using containers.
Run a `Podman`_ container with the latest `PostgreSQL image`_ (replace ``your-password`` with an actual value)::
Run a `Podman`_ container with an `PostgreSQL image`_ (replace ``your-password`` with an actual value)::
podman run --detach --name blockerbugsdb \
--env POSTGRES_PASSWORD=your-password \
--env POSTGRES_DB=blockerbugs \
--env PGDATA=/var/lib/postgresql/pgdata \
--env POSTGRESQL_USER=fedora \
--env POSTGRESQL_PASSWORD=your-password \
--env POSTGRESQL_DATABASE=blockerbugs \
--publish 5432:5432 \
docker.io/library/postgres:13
quay.io/fedora/postgresql-16:latest
.. note::
The container uses a non-standard path for ``PGDATA``, so that the database is stored inside the actual container (and not on a mounted volume). That allows you to later use ``podman commit`` to easily back up and experiment with database structure and contents, see :ref:`manipulating-database`.
The database is stored inside the actual container (and not on a mounted volume). That allows you to later use ``podman commit`` to easily back up and experiment with database structure and contents, see :ref:`manipulating-database`.
In case you wanted to have database fully persistent, add a volume mapping internal folder `/var/lib/pgsql/data` like this: ``--volume /your/data/dir:/var/lib/pgsql/data:Z``.
Wait 10 seconds to let the dabatase initialize and then check that it works::
Wait 10 seconds to let the database initialize and then check that it works::
$ podman exec -it blockerbugsdb psql --host=localhost --username=postgres --command='select version();'
Password for user postgres:
$ podman exec -it blockerbugsdb psql --host=localhost --username=fedora --dbname=blockerbugs --command='select version();'
Password for user fedora:
version
---------------------------------------
PostgreSQL 13.3 (Debian 13.3-1.pgdg100+1) on x86_64-pc-linux-gnu ...
PostgreSQL 13.x ...
(1 row)
Hint: With Podman, you can start, stop, and list existing containers like this::
@ -59,7 +60,7 @@ Hint: With Podman, you can start, stop, and list existing containers like this::
We used to recommend SQLite for development, because it's trivial to set up and use. However it comes with serious disadvantages. The database upgrades (performed using :ref:`Alembic <using-alembic>`) fail for certain operations, or you might end up with a different schema. That's why SQLite is no longer recommended and all developers should use PostgreSQL instead.
.. _Podman: https://podman.io/whatis.html
.. _PostgreSQL image: https://hub.docker.com/_/postgres
.. _PostgreSQL image: https://quay.io/repository/fedora/postgresql-16
Create a Virtualenv
@ -95,7 +96,7 @@ Configuring dev environment
To generate an initial config using a PostgreSQL database, run the following command from the project root (replace ``your-password`` with an actual value)::
./run cli generate_config -d 'postgresql+psycopg://postgres:your-password@localhost:5432/blockerbugs'
./run cli generate_config -d 'postgresql+psycopg://fedora:your-password@localhost:5432/blockerbugs'
The configuration file will be generated in the ``conf/`` directory. While you
would copy this config file to ``/etc/blockerbugs`` in a production system, for
@ -116,9 +117,9 @@ a recent Fedora release is to run::
./run setup
This script will initialize the configured database, set up a release and
several milestones. The script is kept up to date for current Fedora releases
so that you don't need to figure out the relevant blocker and Freeze Exception
tracker bugs.
several milestones. The script contains hardcoded tracker bug IDs for a
specific Fedora release (check the ``run`` script for the current values).
If you need tracker IDs for a different release, see the `Trackers wiki page`_.
Initializing the environment (manual method)
--------------------------------------------

View file

@ -30,7 +30,9 @@ some of the tools used in addtion to build instructions.
Markdown documents
------------------
`Review voting <review-voting.md>`_ - Instructions how to use the review bot in Pagure discussions for the purpose of Fedora blockers review.
`Review voting <review-voting.md>`_ - Instructions how to use the review bot in Forge discussions for the purpose of Fedora blockers review.
`Usage <usage.md>`_ - Troubleshooting guide for Forgejo integration and voting.
TODO
----
@ -146,7 +148,7 @@ in tooltips. `qTip2 <http://craigsworks.com/projects/qtip2/>`_ is used to manage
and display those tooltips.
.. _bugzilla: https://bugzilla.redhat.com/
.. _bodhi: 'https://bodhi.fedoraproject.org/'
.. _bodhi: https://bodhi.fedoraproject.org/
Indices and tables
==================

View file

@ -114,3 +114,78 @@ revision::
blockerbugs upgrade_db
service httpd restart
Forgejo Integration
===================
BlockerBugs integrates with Forgejo (Fedora Forge) for discussion and voting on
blocker bugs. The following settings need to be configured in your
``settings.py`` configuration file.
Forgejo Configuration Settings
------------------------------
Required settings::
FORGEJO_URL = "https://forge.fedoraproject.org/"
FORGEJO_API = "https://forge.fedoraproject.org/api/v1/"
FORGEJO_REPO = "quality/blocker-review"
FORGEJO_BOT_ACCESS_TOKEN = "your-forgejo-api-token"
FORGEJO_REPO_WEBHOOK_SECRET = "your-webhook-secret"
FORGEJO_BOT_USERNAME = "blockerbot"
FORGEJO_BOT_ENABLED = True
FORGEJO_BOT_LOOP_THRESHOLD = 2
FORGEJO_ADMIN_ORG = "quality"
Configuration details:
* ``FORGEJO_URL`` - Base URL of your Forgejo instance
* ``FORGEJO_API`` - API endpoint (usually ``{FORGEJO_URL}/api/v1/``)
* ``FORGEJO_REPO`` - Repository in ``owner/repo`` format
* ``FORGEJO_BOT_ACCESS_TOKEN`` - API token for the bot account, granting access
to Forgejo API. Create it in Forgejo under **Settings****Applications**
**Access tokens**. Required token permissions: **issue - Read and write**
* ``FORGEJO_REPO_WEBHOOK_SECRET`` - Secret key for webhook signature
verification (see `Setting up Webhooks`_ below)
* ``FORGEJO_BOT_USERNAME`` - Username of the bot account
* ``FORGEJO_BOT_ENABLED`` - Enable/disable bot processing (``True``/``False``)
* ``FORGEJO_BOT_LOOP_THRESHOLD`` - Number of consecutive bot comments before
loop detection triggers
* ``FORGEJO_ADMIN_ORG`` - Forgejo organization whose **public members** are
allowed to use admin commands (AGREED/REVOTE) in discussion issues. Members
must set their org membership to public visibility in Forgejo.
Optional settings for customizing discussion issue templates::
FORGEJO_DISCUSSION_TITLE = "[$component] $summary | rhbz#$bugid"
FORGEJO_DISCUSSION_CONTENT = "..."
* ``FORGEJO_DISCUSSION_TITLE`` - Template for discussion issue titles. Available
variables: ``$component``, ``$summary``, ``$bugid``
* ``FORGEJO_DISCUSSION_CONTENT`` - Template for discussion issue body content.
Available variables: ``$bug_url``, ``$blockerbugs_url``, ``$bugid``,
``$bug_img``, ``$vote_summary``, ``$forgejo_url``, ``$forgejo_repo``
Both have sensible defaults and typically do not need to be changed.
Setting up Webhooks
-------------------
To enable real-time vote processing, configure a webhook in your Forgejo
repository:
1. Go to repository **Settings****Webhooks**
2. Click **Add Webhook****Forgejo**
3. Set the webhook URL: ``{BLOCKERBUGS_URL}/api/v0/webhook/forgejo``
4. Set the secret to match ``FORGEJO_REPO_WEBHOOK_SECRET``
5. Select **Content type**: ``application/json``
6. Select **HTTP method**: ``POST``
7. Select trigger events: **Custom events** → all **Issue** events
8. Save the webhook
Testing the webhook:
* Post a test comment in a discussion issue: ``BetaBlocker +1``
* The bot should update the issue description with vote tallies
* Check BlockerBugs logs for any errors

View file

@ -1,5 +1,5 @@
[//]: # (This is a comment)
[//]: # (Note: This file is present in both https://forge.fedoraproject.org/quality/blockerbugs and https://pagure.io/fedora-qa/blocker-review. Please try to keep them synchronized.)
[//]: # (Note: This file is present in both https://forge.fedoraproject.org/quality/blockerbugs and https://forge.fedoraproject.org/quality/blocker-review. Please try to keep them synchronized.)
## Fedora Blocker Review discussions
This is a place where Fedora contributors vote on Fedora release blockers. The process is described here:<br/>
@ -14,7 +14,7 @@ It is not intended that you navigate through this repo's tickets manually. Inste
https://qa.fedoraproject.org/blockerbugs/<br/>
then select a desired milestone and for each proposed blocker/freeze exception you'll see a **Vote** link displayed, which will direct you to the matching discussion ticket.
If a bug report is very complex and requires real-time communication instead of asynchronous ticket discussions, we'll tag the ticket with the [meeting tag](https://pagure.io/fedora-qa/blocker-review/issues?status=Open&tags=meeting) and the discussion will take place during a blocker bug meeting (see above) instead of here.
If a bug report is very complex and requires real-time communication instead of asynchronous ticket discussions, or if we don't have enough votes in the ticket, we'll use the blocker bug meeting (see above) to decide the vote.
You can **Watch issues** in this repo, which will send you an email for every new proposed bug and every new comment in any ticket. If you wish to participate in blocker review discussions in general and not just in a single topic, this is the best way to subscribe.
@ -71,7 +71,7 @@ https://fedoraproject.org/wiki/QA:SOP_blocker_bug_process
### Administrative commands
Members of the [fedora-qa](https://pagure.io/group/fedora-qa) Pagure group can issue administrative commands. Those commands are handled in the same way as voting commands, i.e. they need to be placed on a separate line and are case-insensitive. Those commands include:
Members of the [Quality](https://forge.fedoraproject.org/quality/) Forge organization can issue administrative commands. Those commands are handled in the same way as voting commands, i.e. they need to be placed on a separate line and are case-insensitive. Those commands include:
`AGREED (Accepted|Rejected)TRACKER... [JUSTIFICATION]`

35
docs/source/usage.md Normal file
View file

@ -0,0 +1,35 @@
# Usage
## Voting in Forgejo Discussions
For detailed instructions on how to vote on blocker bugs and freeze exceptions
in Forgejo discussion issues, including vote syntax, admin commands (AGREED,
REVOTE), and usage notes, see [Review Voting](review-voting.md).
## Troubleshooting
### Webhook not firing
* Check webhook configuration in Forgejo repository settings
* Verify webhook URL is accessible from Forgejo
* Check Forgejo webhook delivery logs
* Verify secret matches `FORGEJO_REPO_WEBHOOK_SECRET` configuration
### Votes not being counted
* Check bot username matches `FORGEJO_BOT_USERNAME` configuration
* Verify vote syntax is correct (no extra text on vote lines)
* Check application logs for parsing errors
* Test with `blockerbugs update-discussion <issue_number>`
### Admin commands not working
* Verify user is a **public member** of the configured `FORGEJO_ADMIN_ORG`
* Check that the user's org membership visibility is set to public in Forgejo
* Review application logs for permission errors
### Bot loop detection
* Bot stops updating after `FORGEJO_BOT_LOOP_THRESHOLD` consecutive comments
* Check for webhook configuration issues causing duplicate deliveries
* Verify issue is not triggering multiple events

27
pyproject.toml Normal file
View file

@ -0,0 +1,27 @@
[tool.black]
line-length = 100
[tool.pytest.ini_options]
minversion = "2.0"
python_functions = ["test", "should"]
python_files = ["test_*", "testfunc_*"]
testpaths = ["testing"]
addopts = "--cov-config=setup.cfg --cov-report term-missing --cov-report html:build/coverage/"
filterwarnings = [
"ignore::DeprecationWarning:ast",
"ignore::DeprecationWarning:dateutil",
"ignore::DeprecationWarning:flask_admin",
"ignore::DeprecationWarning:flask_sqlalchemy",
"ignore::DeprecationWarning:openid",
"ignore::DeprecationWarning:pkg_resources",
"ignore::DeprecationWarning:werkzeug",
"ignore::DeprecationWarning:testcontainers",
]
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] (%(filename)s:%(lineno)s) %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
log_file = "pytest.log"
log_file_level = "INFO"
log_file_format = "%(asctime)s [%(levelname)8s] (%(filename)s:%(lineno)s) %(message)s"
log_file_date_format = "%Y-%m-%d %H:%M:%S"

View file

@ -1,3 +1,6 @@
# tests use postgresql and forgejo containers
testcontainers ~= 4.14.0
## linters
# this automatically also pulls rope and flake8 (which pulls pyflakes, pycodestyle and mccabe)
python-language-server[rope,flake8]

View file

@ -17,6 +17,7 @@ munch
psycopg[binary,pool]
py
pycurl
pyforgejo
pytest-cov
pytest
# bugzilla 3.2.0 because of API auth changes:

View file

@ -31,22 +31,6 @@ ignore_missing_imports = True
[mypy-bodhi.*]
ignore_missing_imports = True
[tool:pytest]
minversion = 2.0
python_functions=test should
python_files=test_* testfunc_*
testpaths = testing
addopts = --cov-config=setup.cfg --cov-report term-missing --cov-report html:build/coverage/
filterwarnings =
ignore::DeprecationWarning:ast
ignore::DeprecationWarning:dateutil
ignore::DeprecationWarning:flask_admin
ignore::DeprecationWarning:flask_sqlalchemy
ignore::DeprecationWarning:openid
ignore::DeprecationWarning:pkg_resources
ignore::DeprecationWarning:werkzeug
[coverage:run]
source = blockerbugs

View file

@ -18,37 +18,824 @@
"""Configuration and utils for pytest"""
import logging
import os
import time
import uuid
from typing import Callable, Dict, Any, Generator, Optional, Tuple
from unittest.mock import patch
import docker
import pytest
import requests
import sqlalchemy
from sqlalchemy.exc import SQLAlchemyError
from testcontainers.core.container import DockerContainer
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
from testcontainers.postgres import PostgresContainer
from testing.db_container import DbContainerConfig, is_db_available
from testing.forgejo_container import (
ForgejoApiClientConfig,
ForgejoContainerConfig,
ForgejoRepoConfig,
is_forgejo_available,
)
from testing.service_container import ServiceContainer, ensure_podman_environment
LOGGER = logging.getLogger(__name__)
# Reduce noise from testcontainers modules
logging.getLogger("testcontainers").setLevel(logging.ERROR)
logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
logging.getLogger("docker.auth").setLevel(logging.WARNING)
logging.getLogger("docker.utils.config").setLevel(logging.WARNING)
# --- Database container configuration ---
DB_USER = "blockerbugs"
DB_PASSWORD = "blockerbugs"
DB_NAME = "blockerbugs"
DB_IMAGE = "quay.io/fedora/postgresql-16"
DB_SERVICE = ServiceContainer(name="blockerbugs-test-db", container_port=5432)
# --- Forgejo container configuration ---
FORGEJO_IMAGE = "codeberg.org/forgejo/forgejo:14-rootless"
FORGEJO_SERVICE = ServiceContainer(name="blockerbugs-test-forgejo", container_port=3000)
FORGEJO_ADMIN_USER = "testadmin"
FORGEJO_ADMIN_PASSWORD = "testpassword123"
FORGEJO_ADMIN_EMAIL = "admin@example.com"
FORGEJO_TEST_USER = "testuser"
FORGEJO_TEST_PASSWORD = "testpassword123"
FORGEJO_TEST_EMAIL = "testuser@example.com"
FORGEJO_TEST_ORG = "testorg"
FORGEJO_TEST_TEAM = "testteam"
def pytest_configure(config):
def pytest_configure(config): # pylint: disable=unused-argument
"""This is executed before testing starts"""
# make sure that the testing config is used
os.environ['TEST'] = 'true'
os.environ["TEST"] = "true"
# and that another config is not specified by mistake
os.environ.pop('DEV', None)
os.environ.pop('PROD', None)
os.environ.pop("DEV", None)
os.environ.pop("PROD", None)
@pytest.fixture
def app_ctx(monkeypatch):
"""Run the decorated function/method under the Flask app context (necessary whenever touching
the DB).
Read more at:
https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/contexts/
https://flask.palletsprojects.com/en/2.2.x/appcontext/
https://docs.pytest.org/en/7.2.x/how-to/fixtures.html
# Type alias for container exec functions
ExecFn = Callable[[str], Tuple[int, bytes]]
Note: The application context must be the same during the whole test lifecycle (including setup
and teardown methods, when used). You can't use multiple `with app.app_context():` blocks,
because each block creates a new `db.session()`. You'll then often encounter one of two errors,
either "Instance I is not bound to a Session" or "Object O is already attached to session N".
Flask itself uses the *same* session (and an app context) when handling a single request, so we
need to do it manually the same way.
def _setup_forgejo_org_and_team(
api_url: str,
admin_token: str,
org_name: str,
team_name: str,
member_username: str,
member_token: str,
) -> None:
"""Create a Forgejo organization and team, and add a member.
All operations are idempotent existing org/team/membership is
handled gracefully.
Args:
api_url: Forgejo API base URL
admin_token: Admin API token for authentication
org_name: Organization name to create
team_name: Team name to create within the organization
member_username: Username to add to the team
member_token: API token for the member (used to publicize membership)
Raises:
RuntimeError: If any API call fails unexpectedly
"""
# this import must happen here, because pytest_configure() must run first
from blockerbugs import app
monkeypatch.setattr(app, 'secret_key', 'testing-secret')
auth_headers = {
"Authorization": f"token {admin_token}",
"Content-Type": "application/json",
}
# Create organization
org_response = requests.post(
f"{api_url}orgs",
headers=auth_headers,
json={
"username": org_name,
"description": "Test organization for integration tests",
"visibility": "public",
},
timeout=10,
)
if org_response.status_code not in (200, 201, 409, 422):
raise RuntimeError(f"Failed to create organization: {org_response.text}")
# Create team
team_response = requests.post(
f"{api_url}orgs/{org_name}/teams",
headers=auth_headers,
json={
"name": team_name,
"description": "Test team for integration tests",
"permission": "write",
"units": ["repo.code", "repo.issues", "repo.pulls"],
},
timeout=10,
)
if team_response.status_code not in (200, 201, 409, 422):
raise RuntimeError(f"Failed to create team: {team_response.text}")
# Get team ID — from the create response if new, otherwise look it up
if team_response.status_code in (200, 201):
team_id: int = team_response.json()["id"]
else:
teams_response = requests.get(
f"{api_url}orgs/{org_name}/teams",
headers=auth_headers,
timeout=10,
)
teams_data = teams_response.json()
team_id_or_none = next(
(t["id"] for t in teams_data if t["name"] == team_name),
None,
)
if team_id_or_none is None:
raise RuntimeError(f"Team '{team_name}' not found in org '{org_name}'")
team_id = team_id_or_none
# Add member to team
add_response = requests.put(
f"{api_url}teams/{team_id}/members/{member_username}",
headers=auth_headers,
timeout=10,
)
if add_response.status_code not in (200, 201, 204):
raise RuntimeError(f"Failed to add {member_username} to team: " f"{add_response.text}")
# Publicize membership so org_is_public_member API works.
# This must be done using the member's own token — Forgejo does not
# allow admins to publicize another user's membership.
member_headers = {
"Authorization": f"token {member_token}",
"Content-Type": "application/json",
}
pub_response = requests.put(
f"{api_url}orgs/{org_name}/public_members/{member_username}",
headers=member_headers,
timeout=10,
)
if pub_response.status_code not in (200, 204):
raise RuntimeError(
f"Failed to publicize {member_username} in {org_name}: "
f"{pub_response.text}"
)
def _create_forgejo_token_via_api(
api_url: str,
username: str,
password: str,
token_name: str,
) -> str:
"""Create a Forgejo access token via REST API, deleting any existing one first.
Uses HTTP Basic Auth for authentication.
Args:
api_url: Forgejo API base URL (e.g. http://host:port/api/v1/)
username: Forgejo username
password: Forgejo user password (for Basic Auth)
token_name: Name for the access token
Returns:
The access token string
Raises:
RuntimeError: If token creation fails
"""
# Delete existing token (ignore errors — token may not exist)
try:
requests.delete(
f"{api_url}users/{username}/tokens/{token_name}",
auth=(username, password),
timeout=5,
)
except requests.exceptions.RequestException:
pass
# Create fresh token via API
response = requests.post(
f"{api_url}users/{username}/tokens",
auth=(username, password),
json={"name": token_name, "scopes": ["all"]},
timeout=10,
)
if response.status_code not in (200, 201):
raise RuntimeError(
f"Failed to create API token for {username}: "
f"{response.text}"
)
return response.json()["sha1"]
def _create_forgejo_user_via_api(
api_url: str,
admin_token: str,
username: str,
password: str,
email: str,
) -> None:
"""Create a Forgejo user via the admin REST API.
Idempotent ignores 'already exists' errors.
Args:
api_url: Forgejo API base URL
admin_token: Admin API token for authentication
username: Username to create
password: User password
email: User email address
Raises:
RuntimeError: If user creation fails unexpectedly
"""
response = requests.post(
f"{api_url}admin/users",
headers={
"Authorization": f"token {admin_token}",
"Content-Type": "application/json",
},
json={
"username": username,
"password": password,
"email": email,
"must_change_password": False,
},
timeout=10,
)
if response.status_code not in (200, 201, 409, 422):
raise RuntimeError(
f"Failed to create user {username}: {response.text}"
)
def _setup_forgejo_via_api(
api_url: str,
base_url: str,
) -> tuple:
"""Set up Forgejo users, tokens, and org/team via REST API.
Used in CI where no Docker/podman socket is available.
The first registered user automatically becomes admin.
Args:
api_url: Forgejo API base URL (e.g. http://host:port/api/v1/)
base_url: Forgejo base URL (e.g. http://host:port)
Returns:
Tuple of (admin_token, test_token)
Raises:
RuntimeError: If any setup step fails
"""
# Register admin user — first user becomes admin automatically
# Uses the web sign-up form endpoint
requests.post(
f"{base_url}/user/sign_up",
data={
"user_name": FORGEJO_ADMIN_USER,
"password": FORGEJO_ADMIN_PASSWORD,
"retype": FORGEJO_ADMIN_PASSWORD,
"email": FORGEJO_ADMIN_EMAIL,
},
timeout=10,
)
# Sign-up may return redirect (302) on success or error page
# if user already exists — we verify by creating a token next
# Create admin token
admin_token = _create_forgejo_token_via_api(
api_url, FORGEJO_ADMIN_USER, FORGEJO_ADMIN_PASSWORD, "test-token",
)
# Create test user via admin API
_create_forgejo_user_via_api(
api_url, admin_token,
FORGEJO_TEST_USER, FORGEJO_TEST_PASSWORD, FORGEJO_TEST_EMAIL,
)
# Create test user token
test_token = _create_forgejo_token_via_api(
api_url, FORGEJO_TEST_USER, FORGEJO_TEST_PASSWORD, "test-user-token",
)
# Set up org and team
_setup_forgejo_org_and_team(
api_url, admin_token,
FORGEJO_TEST_ORG, FORGEJO_TEST_TEAM, FORGEJO_TEST_USER,
member_token=test_token,
)
return admin_token, test_token
def _create_forgejo_user_via_cli(
exec_fn: ExecFn,
username: str,
password: str,
email: str,
is_admin: bool = False,
) -> None:
"""Create a Forgejo user via CLI if it does not already exist.
Args:
exec_fn: Function to execute commands in the container
username: Forgejo username
password: User password
email: User email address
is_admin: Whether the user should be an admin
Raises:
RuntimeError: If user creation fails (and user doesn't already exist)
"""
cmd = (
f"forgejo admin user create "
f"--username {username} "
f"--password {password} "
f"--email {email}"
)
if is_admin:
cmd += " --admin"
else:
cmd += " --must-change-password=false"
exit_code, output = exec_fn(cmd)
if exit_code != 0 and "already exists" not in output.decode("utf-8"):
raise RuntimeError(
f"Failed to create user {username}: {output.decode('utf-8')}"
)
def _create_forgejo_token_via_cli(
exec_fn: ExecFn,
api_url: str,
username: str,
password: str,
token_name: str,
) -> str:
"""Create a Forgejo access token via CLI, deleting any existing one first.
Forgejo 14 CLI has no ``delete-access-token`` subcommand, so
deletion is done via the REST API using HTTP Basic Auth.
Args:
exec_fn: Function to execute commands in the container
api_url: Forgejo API base URL (e.g. http://host:port/api/v1/)
username: Forgejo username
password: Forgejo user password (for Basic Auth)
token_name: Name for the access token
Returns:
The access token string
Raises:
RuntimeError: If token creation fails
"""
# Delete existing token via API (ignore errors — token may not exist)
try:
requests.delete(
f"{api_url}users/{username}/tokens/{token_name}",
auth=(username, password),
timeout=5,
)
except requests.exceptions.RequestException:
pass
# Create fresh token via CLI
exit_code, output = exec_fn(
f"forgejo admin user generate-access-token "
f"--username {username} "
f"--token-name {token_name} "
f"--scopes all"
)
if exit_code != 0:
raise RuntimeError(
f"Failed to create API token for {username}: "
f"{output.decode('utf-8')}"
)
# Extract token
# (format: "Access token was successfully created: <token>")
return output.decode("utf-8").strip().split(":")[-1].strip()
def _setup_forgejo_via_cli(
exec_fn: ExecFn,
api_url: str,
) -> tuple:
"""Set up Forgejo users, tokens, and org/team via CLI exec.
Used locally where Docker/podman socket is available.
Args:
exec_fn: Function to execute commands in the container
api_url: Forgejo API base URL
Returns:
Tuple of (admin_token, test_token)
Raises:
RuntimeError: If any setup step fails
"""
_create_forgejo_user_via_cli(
exec_fn,
FORGEJO_ADMIN_USER, FORGEJO_ADMIN_PASSWORD,
FORGEJO_ADMIN_EMAIL, is_admin=True,
)
admin_token = _create_forgejo_token_via_cli(
exec_fn, api_url,
FORGEJO_ADMIN_USER, FORGEJO_ADMIN_PASSWORD, "test-token",
)
_create_forgejo_user_via_cli(
exec_fn,
FORGEJO_TEST_USER, FORGEJO_TEST_PASSWORD,
FORGEJO_TEST_EMAIL,
)
test_token = _create_forgejo_token_via_cli(
exec_fn, api_url,
FORGEJO_TEST_USER, FORGEJO_TEST_PASSWORD, "test-user-token",
)
_setup_forgejo_org_and_team(
api_url, admin_token,
FORGEJO_TEST_ORG, FORGEJO_TEST_TEAM, FORGEJO_TEST_USER,
member_token=test_token,
)
return admin_token, test_token
@pytest.fixture(name="forgejo_container", scope="session")
def _forgejo_container() -> Generator[ForgejoContainerConfig, None, None]:
"""
Pytest fixture that provides a Forgejo instance for testing.
In CI (detected via CI env var), connects to the Forgejo Actions
service container using the name and port from ``FORGEJO_SERVICE``.
Locally, checks for an existing named container. If found (running
or stopped), its dynamically assigned host port is used. If no
usable container exists, a new testcontainer is started with a
random host port to avoid conflicts with other services on the host.
The testcontainer is NOT automatically removed after tests complete,
allowing reuse across subsequent test runs.
To manually remove it, run: podman rm -f blockerbugs-test-forgejo
In both CI and local modes, the same setup logic runs: create admin
user, API tokens, test user, organization, and team.
Yields:
ForgejoContainerConfig: Forgejo connection and test account configuration
"""
is_ci = bool(os.getenv("CI"))
LOGGER.info("Forgejo: running in %s environment", "CI" if is_ci else "local development")
container = None
port: Optional[int] = None
if is_ci:
# In CI, use the Forgejo Actions service container
host = FORGEJO_SERVICE.name
port = FORGEJO_SERVICE.ci_port
else:
# Locally, try to reuse an existing named container
if not ensure_podman_environment():
pytest.exit("Failed to set up podman environment")
port = FORGEJO_SERVICE.get_existing_port()
if port is not None:
host = "localhost"
LOGGER.info("Reusing existing Forgejo container on port %s", port)
else:
# No existing container, start a new one
FORGEJO_SERVICE.remove()
LOGGER.info("Starting new Forgejo container: %s", FORGEJO_SERVICE.name)
container = DockerContainer(FORGEJO_IMAGE)
container.with_exposed_ports(3000)
container.with_env("FORGEJO__security__INSTALL_LOCK", "true")
container.with_env("FORGEJO__server__OFFLINE_MODE", "true")
container.with_env("FORGEJO__database__DB_TYPE", "sqlite3")
container.with_env("FORGEJO__security__SECRET_KEY", "test-secret-key-for-testing-only")
container.with_env(
"FORGEJO__security__INTERNAL_TOKEN", "test-internal-token-for-testing"
)
# Set user to git (UID 1000) - the default non-root user
# in Forgejo rootless images
container.with_kwargs(user="1000:1000")
container.with_name(FORGEJO_SERVICE.name)
container.waiting_for(
LogMessageWaitStrategy(
"Starting new Web server: tcp:0.0.0.0:3000"
).with_startup_timeout(60)
)
container.start()
# Give it a bit more time to fully initialize
time.sleep(3)
port = int(container.get_exposed_port(3000))
host = "localhost"
# Wait for the API to become ready
if not is_forgejo_available(host, port):
raise RuntimeError("Forgejo did not become ready in time")
api_url: str = f"http://{host}:{port}/api/v1/"
# --- Setup: create users, tokens, org, team ---
# These commands are idempotent (handle "already exists" gracefully)
base_url: str = f"http://{host}:{port}"
if is_ci:
# CI: no Docker socket available — use REST API exclusively
admin_token, test_token = _setup_forgejo_via_api(api_url, base_url)
else:
# Local: use CLI exec for user/token creation
docker_client = None
if container is not None:
# Fresh testcontainer — use its exec method directly
def exec_in_container(cmd: str):
return container.exec(cmd)
else:
# Reusing existing container — use docker SDK
docker_client = docker.from_env()
sdk_container = docker_client.containers.get(
FORGEJO_SERVICE.name)
def exec_in_container(cmd: str):
return sdk_container.exec_run(cmd)
try:
admin_token, test_token = _setup_forgejo_via_cli(
exec_in_container, api_url)
finally:
if docker_client is not None:
docker_client.close()
# Yield configuration to tests
yield ForgejoContainerConfig(
host=host,
port=port,
admin_user=FORGEJO_ADMIN_USER,
admin_password=FORGEJO_ADMIN_PASSWORD,
admin_email=FORGEJO_ADMIN_EMAIL,
admin_token=admin_token,
test_user=FORGEJO_TEST_USER,
test_password=FORGEJO_TEST_PASSWORD,
test_email=FORGEJO_TEST_EMAIL,
test_token=test_token,
test_org=FORGEJO_TEST_ORG,
test_team=FORGEJO_TEST_TEAM,
)
# Container persists locally for reuse; no stop() call
@pytest.fixture(name="forgejo_api_client")
def _forgejo_api_client(forgejo_container: ForgejoContainerConfig) -> ForgejoApiClientConfig:
"""
Pytest fixture that provides an authenticated API client for Forgejo.
Uses the normal test user credentials by default.
Args:
forgejo_container: The forgejo_container session fixture
Returns:
ForgejoApiClientConfig: API client configuration with url, token, and headers
"""
return ForgejoApiClientConfig(
api_url=forgejo_container.api_url,
token=forgejo_container.test_token,
)
@pytest.fixture(name="forgejo_test_repo")
def _forgejo_test_repo(
forgejo_api_client: ForgejoApiClientConfig,
) -> Generator[ForgejoRepoConfig, None, None]:
"""
Pytest fixture that creates a test repository in Forgejo.
The repository is created fresh for each test and can be used
for testing issue creation, comments, etc.
Args:
forgejo_api_client: The forgejo_api_client fixture
Yields:
ForgejoRepoConfig: Test repository configuration
"""
# Create unique repository name
repo_name: str = f"test-repo-{uuid.uuid4().hex[:8]}"
# Create repository via API
response: requests.Response = requests.post(
f"{forgejo_api_client.api_url}user/repos",
headers=forgejo_api_client.headers,
json={
"name": repo_name,
"description": "Test repository for integration tests",
"private": False,
"auto_init": True,
},
timeout=10,
)
if response.status_code not in (200, 201):
raise RuntimeError(f"Failed to create test repository: {response.text}")
repo_data: Dict[str, Any] = response.json()
repo_config = ForgejoRepoConfig(
owner=repo_data["owner"]["login"],
name=repo_data["name"],
full_name=repo_data["full_name"],
)
yield repo_config
# Cleanup: delete repository after test
requests.delete(
f"{forgejo_api_client.api_url}repos/{repo_data['full_name']}",
headers=forgejo_api_client.headers,
timeout=10,
)
@pytest.fixture(name="postgres_container", scope="session")
def _postgres_container() -> Generator[DbContainerConfig, None, None]:
"""
Pytest fixture that provides a PostgreSQL connection URL.
In CI (detected via CI env var), connects to the Forgejo Actions
service container using the name and port from ``DB_SERVICE``.
Locally, checks for an existing named container. If found (running
or stopped), its dynamically assigned host port is used and the
database is probed for connectivity. If no usable container exists,
a new testcontainer is started with a random host port to avoid
conflicts with other services on the host.
The testcontainer is NOT automatically removed after tests complete,
allowing reuse across subsequent test runs.
To manually remove it, run: podman rm -f blockerbugs-test-db
Yields:
DbContainerConfig: Database connection configuration
"""
is_ci = bool(os.getenv("CI"))
LOGGER.info("PostgreSQL: running in %s environment", "CI" if is_ci else "local development")
# Determine host and port for an existing container
port: Optional[int] = None
if is_ci:
host = DB_SERVICE.name
port = DB_SERVICE.ci_port
else:
if not ensure_podman_environment():
pytest.exit("Failed to set up podman environment")
host = "localhost"
port = DB_SERVICE.get_existing_port()
# Try to connect to existing container
if port is not None:
config = DbContainerConfig(
host=host,
port=port,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
)
if is_db_available(config):
yield config
return
if is_ci:
pytest.exit(f"Failed to connect to CI service container " f"at {host}:{port}")
LOGGER.debug(
"Container '%s' not responsive, removing and recreating",
DB_SERVICE.name,
)
# --- Local only: start new testcontainer ---
DB_SERVICE.remove()
LOGGER.info("Starting new database container: %s", DB_SERVICE.name)
# Note: The Fedora PostgreSQL image uses POSTGRESQL_* env vars,
# not the standard POSTGRES_* vars that testcontainers sets.
postgres = (
PostgresContainer(
DB_IMAGE,
username=DB_USER,
password=DB_PASSWORD,
dbname=DB_NAME,
)
.with_env("POSTGRESQL_USER", DB_USER)
.with_env("POSTGRESQL_PASSWORD", DB_PASSWORD)
.with_env("POSTGRESQL_DATABASE", DB_NAME)
.with_name(DB_SERVICE.name)
)
postgres.start()
port = int(postgres.get_exposed_port(5432))
config = DbContainerConfig(
host="localhost",
port=port,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
)
LOGGER.debug("Test database URI: %s", config.url)
# Container persists after tests for reuse
yield config
@pytest.fixture(scope="function")
def _postgres_db(postgres_container: DbContainerConfig) -> Generator[None, None, None]:
"""
Pytest fixture that configures the Flask app to use PostgreSQL and provides
an application context for database operations.
This fixture is function-scoped, meaning each test gets a fresh database state.
Args:
postgres_container: The postgres_container session fixture
Yields:
None (the app context is active during the test)
"""
# This import must happen here, because pytest_configure() must set
# os.environ['TEST'] before the app is imported
from blockerbugs import app, db # pylint: disable=import-outside-toplevel
app.secret_key = "testing-secret"
# Get the connection URL from container
# The URL already uses the correct psycopg driver
postgres_url = postgres_container.url
# Create PostgreSQL engine directly
postgres_engine = sqlalchemy.create_engine(postgres_url)
# Enter app context - all database operations must be inside this context
with app.app_context():
yield
# Patch db.engine to return our PostgreSQL engine
with patch.object(type(db), "engine", new=postgres_engine):
# Also patch db.get_engine() to return our engine
with patch.object(db, "get_engine", return_value=postgres_engine):
# Clean up any existing sessions
db.session.remove()
# Bind session to PostgreSQL engine
db.session.configure(bind=postgres_engine)
# Setup: create all tables
db.session.rollback()
db.drop_all()
db.create_all()
try:
# Yield to test - test runs with PostgreSQL and inside app context
yield
finally:
# Teardown: clean up database (still inside app context)
try:
db.session.rollback()
db.session.close()
db.drop_all()
except SQLAlchemyError:
pass
# Clean up sessions while still in app context
try:
db.session.remove()
except SQLAlchemyError:
pass
# Cleanup: dispose the PostgreSQL engine
postgres_engine.dispose()

78
testing/db_container.py Normal file
View file

@ -0,0 +1,78 @@
# Copyright 2011, Red Hat, Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""
Database utilities for test infrastructure.
Provides database-specific health checking built on top of SQLAlchemy.
Reusable across projects.
"""
import dataclasses
import logging
import sqlalchemy
import sqlalchemy.exc
LOGGER = logging.getLogger(__name__)
@dataclasses.dataclass
class DbContainerConfig:
"""Database connection configuration."""
host: str
port: int
database: str
user: str
password: str
driver: str = "postgresql+psycopg"
@property
def url(self) -> str:
"""SQLAlchemy-compatible database connection URL."""
return (
f"{self.driver}://{self.user}:{self.password}"
f"@{self.host}:{self.port}/{self.database}"
)
def is_db_available(config: DbContainerConfig) -> bool:
"""
Attempt to connect to a database.
Probes the database with a SELECT 1 query using the connection
URL derived from the provided configuration.
Args:
config: Database connection configuration
Returns:
True if connection succeeds, False otherwise
"""
try:
LOGGER.info("Probing database at %s:%s", config.host, config.port)
engine = sqlalchemy.create_engine(config.url)
with engine.connect() as conn:
conn.execute(sqlalchemy.text("SELECT 1"))
engine.dispose()
LOGGER.info("Connected to database at %s:%s", config.host, config.port)
return True
except (sqlalchemy.exc.SQLAlchemyError, OSError):
LOGGER.debug(
"Failed to connect to database at %s:%s",
config.host, config.port)
return False

0
testing/e2e/__init__.py Normal file
View file

317
testing/e2e/conftest.py Normal file
View file

@ -0,0 +1,317 @@
"""End-to-end test fixtures for bug proposal workflow"""
import os
import time
from typing import Any, Generator
from unittest.mock import MagicMock
import pytest
import requests
# Set TEST environment variable before importing app
os.environ['TEST'] = 'true'
# pylint: disable=wrong-import-position
from blockerbugs import app, db # noqa: E402
from blockerbugs.models.bug import Bug # noqa: E402
from blockerbugs.models.milestone import Milestone # noqa: E402
from blockerbugs.models.release import Release # noqa: E402
from testing.forgejo_container import ForgejoContainerConfig, ForgejoRepoConfig # noqa: E402
# pylint: enable=wrong-import-position
@pytest.fixture
def _test_release_milestone(_postgres_db):
"""Create test Release and Milestone in PostgreSQL database
Returns:
tuple: (Release, Milestone) instances created in the database
"""
release = Release(number=40, active=True)
db.session.add(release)
db.session.commit()
milestone = Milestone(
release=release,
version="beta",
blocker_tracker=123456, # Mock tracker bug IDs
fe_tracker=123457,
name="40-beta",
active=True,
current=True,
)
db.session.add(milestone)
db.session.commit()
return release, milestone
@pytest.fixture
def mock_bz_bug() -> MagicMock:
"""Create a mock Bugzilla bug object with required attributes
Returns:
MagicMock: Mock bug object that simulates Bugzilla API response
"""
mock_bug = MagicMock()
mock_bug.bug_id = 234567
mock_bug.weburl = "https://bugzilla.redhat.com/show_bug.cgi?id=234567"
mock_bug.summary = "Test bug summary for blocker proposal"
mock_bug.status = "NEW"
mock_bug.component = "anaconda"
mock_bug.bug_status = "NEW"
mock_bug.is_open = True
mock_bug.whiteboard = ""
mock_bug.blocked = []
mock_bug.dependson = []
mock_bug.flags = []
# Mock the time structure for last_change_time
mock_bug.last_change_time = MagicMock()
mock_bug.last_change_time.timetuple.return_value = time.struct_time(
(2024, 1, 15, 10, 30, 0, 0, 15, 0)
)
return mock_bug
@pytest.fixture
def mock_closed_bz_bug() -> MagicMock:
"""Create a mock closed Bugzilla bug object
Returns:
MagicMock: Mock bug object for a closed bug
"""
mock_bug = MagicMock()
mock_bug.bug_id = 234568
mock_bug.weburl = "https://bugzilla.redhat.com/show_bug.cgi?id=234568"
mock_bug.summary = "Test closed bug"
mock_bug.status = "CLOSED"
mock_bug.component = "anaconda"
mock_bug.bug_status = "CLOSED ERRATA"
mock_bug.is_open = False
mock_bug.whiteboard = ""
mock_bug.blocked = []
mock_bug.dependson = []
mock_bug.flags = []
mock_bug.last_change_time = MagicMock()
mock_bug.last_change_time.timetuple.return_value = time.struct_time(
(2024, 1, 15, 10, 30, 0, 0, 15, 0)
)
return mock_bug
@pytest.fixture
def mock_already_proposed_bz_bug(_test_release_milestone) -> MagicMock:
"""Create a mock bug that's already blocking the tracker
Args:
_test_release_milestone: Tuple of (release, milestone) fixtures
Returns:
MagicMock: Mock bug object that's already proposed
"""
_, milestone = _test_release_milestone
mock_bug = MagicMock()
mock_bug.bug_id = 234569
mock_bug.weburl = "https://bugzilla.redhat.com/show_bug.cgi?id=234569"
mock_bug.summary = "Test already proposed bug"
mock_bug.status = "NEW"
mock_bug.component = "anaconda"
mock_bug.bug_status = "NEW"
mock_bug.is_open = True
mock_bug.whiteboard = ""
mock_bug.blocked = [milestone.blocker_tracker] # Already blocking the tracker
mock_bug.dependson = []
mock_bug.flags = []
mock_bug.last_change_time = MagicMock()
mock_bug.last_change_time.timetuple.return_value = time.struct_time(
(2024, 1, 15, 10, 30, 0, 0, 15, 0)
)
return mock_bug
@pytest.fixture
def app_config_patch(
forgejo_container: ForgejoContainerConfig, forgejo_test_repo: ForgejoRepoConfig
) -> Generator:
"""Patch app configuration for Forgejo integration
Args:
forgejo_container: Forgejo testcontainer configuration
forgejo_test_repo: Test repository information
Yields:
None (patches are active during test execution)
"""
# Save original values
original_secret = app.secret_key
original_config = {
"FORGEJO_API": app.config.get("FORGEJO_API"),
"FORGEJO_BOT_ACCESS_TOKEN": app.config.get("FORGEJO_BOT_ACCESS_TOKEN"),
"FORGEJO_REPO": app.config.get("FORGEJO_REPO"),
"FORGEJO_URL": app.config.get("FORGEJO_URL"),
"BUGZILLA_URL": app.config.get("BUGZILLA_URL"),
"BLOCKERBUGS_URL": app.config.get("BLOCKERBUGS_URL"),
"BLOCKERBUGS_API": app.config.get("BLOCKERBUGS_API"),
"FORGEJO_DISCUSSION_TITLE": app.config.get("FORGEJO_DISCUSSION_TITLE"),
"FORGEJO_DISCUSSION_CONTENT": app.config.get("FORGEJO_DISCUSSION_CONTENT"),
}
try:
# Set secret key and configuration
app.secret_key = "testing-secret-for-e2e-tests"
app.config["WTF_CSRF_ENABLED"] = False # Disable CSRF for testing
app.config["FORGEJO_API"] = forgejo_container.api_url
app.config["FORGEJO_BOT_ACCESS_TOKEN"] = forgejo_container.test_token
app.config["FORGEJO_REPO"] = forgejo_test_repo.full_name
app.config["FORGEJO_URL"] = forgejo_container.base_url + "/"
app.config["BUGZILLA_URL"] = "https://bugzilla.redhat.com"
app.config["BLOCKERBUGS_URL"] = "http://localhost:5000/"
app.config["BLOCKERBUGS_API"] = "http://localhost:5000/api/"
app.config["FORGEJO_DISCUSSION_TITLE"] = "Bug $bugid - $summary"
app.config["FORGEJO_DISCUSSION_CONTENT"] = "Bug: $bug_url\n\n$vote_summary"
yield
finally:
# Restore original values
app.secret_key = original_secret
for key, value in original_config.items():
if value is not None:
app.config[key] = value
elif key in app.config:
del app.config[key]
@pytest.fixture
def _webhook_secret() -> str:
"""Provide a webhook secret for signature verification
Returns:
str: Test webhook secret
"""
return "test-webhook-secret-for-e2e-testing"
@pytest.fixture
def _voting_app_config(
_postgres_db: Any,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
_webhook_secret: str,
) -> Generator[Any, Any, Any]:
"""Configure app for voting e2e tests
Args:
_postgres_db: PostgreSQL database fixture
forgejo_container: Forgejo testcontainer configuration
forgejo_test_repo: Test repository information
_webhook_secret: Webhook secret for HMAC verification
"""
# Save original values
original_config = {
"FORGEJO_API": app.config.get("FORGEJO_API"),
"FORGEJO_BOT_ACCESS_TOKEN": app.config.get("FORGEJO_BOT_ACCESS_TOKEN"),
"FORGEJO_REPO": app.config.get("FORGEJO_REPO"),
"FORGEJO_URL": app.config.get("FORGEJO_URL"),
"FORGEJO_REPO_WEBHOOK_SECRET": app.config.get("FORGEJO_REPO_WEBHOOK_SECRET"),
"FORGEJO_BOT_ENABLED": app.config.get("FORGEJO_BOT_ENABLED"),
"FORGEJO_BOT_USERNAME": app.config.get("FORGEJO_BOT_USERNAME"),
"FORGEJO_BOT_LOOP_THRESHOLD": app.config.get("FORGEJO_BOT_LOOP_THRESHOLD"),
"FORGEJO_ADMIN_ORG": app.config.get("FORGEJO_ADMIN_ORG"),
}
try:
# Configure app for testing
app.secret_key = "testing-secret-for-voting-e2e"
app.config["FORGEJO_API"] = forgejo_container.api_url
app.config["FORGEJO_BOT_ACCESS_TOKEN"] = forgejo_container.test_token
app.config["FORGEJO_REPO"] = forgejo_test_repo.full_name
app.config["FORGEJO_URL"] = forgejo_container.base_url + "/"
app.config["FORGEJO_REPO_WEBHOOK_SECRET"] = _webhook_secret
app.config["FORGEJO_BOT_ENABLED"] = True
# Use admin user as bot to avoid conflicts with test_user posting votes
app.config["FORGEJO_BOT_USERNAME"] = forgejo_container.admin_user
app.config["FORGEJO_BOT_LOOP_THRESHOLD"] = 5
app.config["FORGEJO_ADMIN_ORG"] = forgejo_container.test_org
yield
finally:
# Restore original values
for key, value in original_config.items():
if value is not None:
app.config[key] = value
elif key in app.config:
del app.config[key]
@pytest.fixture(name="test_bug_with_discussion")
def _test_bug_with_discussion(
_voting_app_config: Any,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
) -> tuple[Bug, int]:
"""Create a test bug with associated Forgejo discussion issue
Args:
voting_app_config: App configuration fixture
forgejo_container: Forgejo testcontainer configuration
forgejo_test_repo: Test repository information
Returns:
tuple: (Bug instance, Forgejo issue number)
"""
# Create release and milestone
release = Release(number=42, active=True)
db.session.add(release)
db.session.commit()
milestone = Milestone(
release=release,
version="beta",
blocker_tracker=123456,
fe_tracker=123457,
name="42-beta",
active=True,
current=True,
)
db.session.add(milestone)
db.session.commit()
# Create Forgejo issue for voting
api_url = forgejo_container.api_url
headers = {
"Authorization": f"token {forgejo_container.test_token}",
"Content-Type": "application/json",
}
issue_response = requests.post(
f"{api_url}repos/{forgejo_test_repo.full_name}/issues",
headers=headers,
json={
"title": "Test Bug 987654 - Test blocker discussion",
"body": "Initial discussion for voting test",
},
timeout=10,
)
issue_data = issue_response.json()
issue_number = issue_data["number"]
# Create bug in database linked to discussion
discussion_url = (
f"{forgejo_container.base_url}/{forgejo_test_repo.full_name}" f"/issues/{issue_number}"
)
bug = Bug(
bugid=987654,
url="https://bugzilla.redhat.com/show_bug.cgi?id=987654",
summary="Test blocker bug for voting",
status="NEW",
component="anaconda",
milestone=milestone,
active=True,
needinfo=False,
needinfo_requestee=None,
)
bug.proposed_blocker = True
bug.proposed_fe = False
bug.discussion_link = discussion_url
db.session.add(bug)
db.session.commit()
return bug, issue_number

View file

@ -0,0 +1,423 @@
"""End-to-end tests for bug proposal workflow
This module tests the complete "propose a bug" user interaction flow including:
- Form submission
- Bugzilla validation and updates (mocked)
- Database synchronization (real PostgreSQL)
- Forgejo discussion issue creation (real Forgejo)
"""
from typing import Any
from unittest.mock import MagicMock, patch
from xmlrpc.client import Fault
import requests
from blockerbugs import app
from blockerbugs.models.release import Release
from blockerbugs.models.milestone import Milestone
from blockerbugs.models.bug import Bug
from testing.forgejo_container import ForgejoContainerConfig, ForgejoRepoConfig
# pylint: disable=unused-argument
class TestProposeBugE2E: # pylint: disable=too-many-locals
"""End-to-end tests for bug proposal workflow"""
def test_propose_bug_as_blocker(
self,
_postgres_db: Any,
_test_release_milestone: tuple[Release, Milestone],
mock_bz_bug: MagicMock,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
app_config_patch: Any,
) -> None:
"""
GIVEN a release with an active milestone
WHEN a user proposes a bug as a blocker
THEN:
1. Bugzilla is checked for bug validity (mocked)
2. Bugzilla is updated with tracker relationship (mocked)
3. Bug is synced to PostgreSQL database
4. Forgejo discussion issue is created
5. Bug.discussion_link is populated
6. User sees thank you page
"""
_, milestone = _test_release_milestone
# Mock Bugzilla interface
mock_bz = MagicMock()
mock_bz.getbug.return_value = mock_bz_bug
mock_bz.build_update.return_value = {}
mock_bz.update_bugs.return_value = None
# Mock BlockerBugs class for BugSync
mock_blocker_bugs = MagicMock()
mock_blocker_bugs.bz = mock_bz
mock_blocker_bugs.query_tracker.return_value = []
# Mock OIDC user
mock_oidc_user = MagicMock()
mock_oidc_user.name = "testuser"
with patch("blockerbugs.controllers.main.get_bugzilla", return_value=mock_bz):
with patch("blockerbugs.util.bz_interface.BlockerBugs", return_value=mock_blocker_bugs):
with patch("blockerbugs.controllers.main.g") as mock_g:
mock_g.oidc_user = mock_oidc_user
# Create Flask test client
with app.test_client() as client:
# Mock the OIDC decorator to allow access without authentication
with patch("blockerbugs.controllers.main.oidc.require_login", lambda f: f):
# Submit bug proposal form
response = client.post(
"/propose_bug",
data={
"bugid": str(mock_bz_bug.bug_id),
"fas_login": "testuser",
"milestone": str(milestone.id),
"blocker": "y",
"justification": "This bug causes anaconda to crash on boot",
},
follow_redirects=False,
)
# Should render thank you page (200) or redirect to it
assert response.status_code in (200, 302)
# Verify bug exists in database
bug = Bug.query.filter_by(
bugid=mock_bz_bug.bug_id, milestone=milestone
).first()
assert bug is not None, "Bug should be created in database"
assert (
bug.proposed_blocker is True
), "Bug should be marked as proposed blocker"
assert bug.proposed_fe is False, "Bug should not be marked as FE"
assert bug.summary == mock_bz_bug.summary
assert bug.status == mock_bz_bug.status
assert bug.component == mock_bz_bug.component
# Verify Forgejo discussion link is created
assert (
bug.discussion_link is not None
), "Bug should have discussion link"
assert (
"/issues/" in bug.discussion_link
), "Discussion link should be a Forgejo issue URL"
# Extract issue number from URL
issue_number = int(bug.discussion_link.split("/")[-1])
# Verify Forgejo issue exists via API
issue_url = (
f"{forgejo_container.api_url}repos/"
f"{forgejo_test_repo.full_name}/issues/{issue_number}"
)
issue_response = requests.get(
issue_url,
headers={
"Authorization": f"token {forgejo_container.test_token}"
},
timeout=10,
)
assert issue_response.status_code == 200, "Forgejo issue should exist"
issue_data = issue_response.json()
assert (
str(mock_bz_bug.bug_id) in issue_data["title"]
), "Issue title should contain bug ID"
assert issue_data["state"] == "open", "Issue should be open"
def test_propose_bug_as_freeze_exception(
self,
_postgres_db: Any,
_test_release_milestone: tuple[Release, Milestone],
mock_bz_bug: MagicMock,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
app_config_patch: Any,
) -> None:
"""
GIVEN a release with an active milestone
WHEN a user proposes a bug as a freeze exception
THEN:
1. Bug is synced to PostgreSQL database
2. Bug is marked as proposed FE (not blocker)
3. Forgejo discussion issue is created
4. Bug.discussion_link is populated
"""
_, milestone = _test_release_milestone
# Use a different bug ID for FE test
mock_bz_bug.bug_id = 234570
# Mock Bugzilla interface
mock_bz = MagicMock()
mock_bz.getbug.return_value = mock_bz_bug
mock_bz.build_update.return_value = {}
mock_bz.update_bugs.return_value = None
# Mock BlockerBugs class for BugSync
mock_blocker_bugs = MagicMock()
mock_blocker_bugs.bz = mock_bz
mock_blocker_bugs.query_tracker.return_value = []
# Mock OIDC user
mock_oidc_user = MagicMock()
mock_oidc_user.name = "testuser"
with patch("blockerbugs.controllers.main.get_bugzilla", return_value=mock_bz):
with patch("blockerbugs.util.bz_interface.BlockerBugs", return_value=mock_blocker_bugs):
with patch("blockerbugs.controllers.main.g") as mock_g:
mock_g.oidc_user = mock_oidc_user
with app.test_client() as client:
with patch("blockerbugs.controllers.main.oidc.require_login", lambda f: f):
response = client.post(
"/propose_bug",
data={
"bugid": str(mock_bz_bug.bug_id),
"fas_login": "testuser",
"milestone": str(milestone.id),
"freeze_exception": "y",
"justification": "This fixes an important user-facing issue",
},
follow_redirects=False,
)
assert response.status_code in (200, 302)
bug = Bug.query.filter_by(
bugid=mock_bz_bug.bug_id, milestone=milestone
).first()
assert bug is not None
assert bug.proposed_fe is True, "Bug should be marked as proposed FE"
assert bug.proposed_blocker is False, "Bug should not be marked as blocker"
assert bug.discussion_link is not None
def test_propose_bug_both_blocker_and_fe(
self,
_postgres_db: Any,
_test_release_milestone: tuple[Release, Milestone],
mock_bz_bug: MagicMock,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
app_config_patch: Any,
) -> None:
"""
GIVEN a release with an active milestone
WHEN a user proposes a bug as both blocker and freeze exception
THEN:
1. Bug is synced to PostgreSQL database
2. Bug is marked with both proposed_blocker and proposed_fe flags
3. Forgejo discussion issue is created
4. Bug.discussion_link is populated
"""
_, milestone = _test_release_milestone
# Use a different bug ID
mock_bz_bug.bug_id = 234571
mock_bz = MagicMock()
mock_bz.getbug.return_value = mock_bz_bug
mock_bz.build_update.return_value = {}
mock_bz.update_bugs.return_value = None
# Mock BlockerBugs class for BugSync
mock_blocker_bugs = MagicMock()
mock_blocker_bugs.bz = mock_bz
mock_blocker_bugs.query_tracker.return_value = []
mock_oidc_user = MagicMock()
mock_oidc_user.name = "testuser"
with patch("blockerbugs.controllers.main.get_bugzilla", return_value=mock_bz):
with patch("blockerbugs.util.bz_interface.BlockerBugs", return_value=mock_blocker_bugs):
with patch("blockerbugs.controllers.main.g") as mock_g:
mock_g.oidc_user = mock_oidc_user
with app.test_client() as client:
with patch("blockerbugs.controllers.main.oidc.require_login", lambda f: f):
response = client.post(
"/propose_bug",
data={
"bugid": str(mock_bz_bug.bug_id),
"fas_login": "testuser",
"milestone": str(milestone.id),
"blocker": "y",
"freeze_exception": "y",
"justification": "Critical bug affecting release criteria",
},
follow_redirects=False,
)
assert response.status_code in (200, 302)
bug = Bug.query.filter_by(
bugid=mock_bz_bug.bug_id, milestone=milestone
).first()
assert bug is not None
assert bug.proposed_blocker is True
assert bug.proposed_fe is True
assert bug.discussion_link is not None
def test_propose_closed_bug_fails(
self,
_postgres_db: Any,
_test_release_milestone: tuple[Release, Milestone],
mock_closed_bz_bug: MagicMock,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
app_config_patch: Any,
) -> None:
"""
GIVEN a closed bug in Bugzilla
WHEN user tries to propose it as a blocker
THEN:
1. Form validation error is displayed
2. Response indicates bug is CLOSED
3. No bug is created in database
4. No Forgejo issue is created
"""
_, milestone = _test_release_milestone
mock_bz = MagicMock()
mock_bz.getbug.return_value = mock_closed_bz_bug
mock_oidc_user = MagicMock()
mock_oidc_user.name = "testuser"
with patch("blockerbugs.controllers.main.get_bugzilla", return_value=mock_bz):
with patch("blockerbugs.controllers.main.g") as mock_g:
mock_g.oidc_user = mock_oidc_user
with app.test_client() as client:
with patch("blockerbugs.controllers.main.oidc.require_login", lambda f: f):
response = client.post(
"/propose_bug",
data={
"bugid": str(mock_closed_bz_bug.bug_id),
"fas_login": "testuser",
"milestone": str(milestone.id),
"blocker": "y",
"justification": "Test",
},
follow_redirects=False,
)
# Should stay on form page (200) with error
assert response.status_code == 200
assert b"CLOSED" in response.data or b"closed" in response.data
# Verify no bug was created
bug = Bug.query.filter_by(
bugid=mock_closed_bz_bug.bug_id, milestone=milestone
).first()
assert bug is None, "Closed bug should not be created in database"
def test_propose_nonexistent_bug_fails(
self,
_postgres_db: Any,
_test_release_milestone: tuple[Release, Milestone],
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
app_config_patch: Any,
) -> None:
"""
GIVEN a non-existent bug ID
WHEN user tries to propose it as a blocker
THEN:
1. Bugzilla returns fault for non-existent bug
2. Form validation error is displayed
3. Response indicates bug does not exist
4. No bug is created in database
"""
_, milestone = _test_release_milestone
mock_bz = MagicMock()
# Simulate Bugzilla fault for non-existent bug
mock_bz.getbug.side_effect = Fault(101, "Bug #999999 does not exist.")
mock_oidc_user = MagicMock()
mock_oidc_user.name = "testuser"
with patch("blockerbugs.controllers.main.get_bugzilla", return_value=mock_bz):
with patch("blockerbugs.controllers.main.g") as mock_g:
mock_g.oidc_user = mock_oidc_user
with app.test_client() as client:
with patch("blockerbugs.controllers.main.oidc.require_login", lambda f: f):
response = client.post(
"/propose_bug",
data={
"bugid": "999999",
"fas_login": "testuser",
"milestone": str(milestone.id),
"blocker": "y",
"justification": "Test",
},
follow_redirects=False,
)
# Should stay on form page with error
assert response.status_code == 200
assert (
b"does not exist" in response.data or b"error" in response.data.lower()
)
# Verify no bug was created
bug = Bug.query.filter_by(bugid=999999, milestone=milestone).first()
assert bug is None
def test_propose_already_proposed_bug_fails(
self,
_postgres_db: Any,
_test_release_milestone: tuple[Release, Milestone],
mock_already_proposed_bz_bug: MagicMock,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
app_config_patch: Any,
) -> None:
"""
GIVEN a bug already blocking the tracker
WHEN user tries to propose it again as a blocker
THEN:
1. Form validation error is displayed
2. Response indicates bug is already proposed
3. Duplicate proposal is rejected
"""
_, milestone = _test_release_milestone
mock_bz = MagicMock()
mock_bz.getbug.return_value = mock_already_proposed_bz_bug
mock_oidc_user = MagicMock()
mock_oidc_user.name = "testuser"
with patch("blockerbugs.controllers.main.get_bugzilla", return_value=mock_bz):
with patch("blockerbugs.controllers.main.g") as mock_g:
mock_g.oidc_user = mock_oidc_user
with app.test_client() as client:
with patch("blockerbugs.controllers.main.oidc.require_login", lambda f: f):
response = client.post(
"/propose_bug",
data={
"bugid": str(mock_already_proposed_bz_bug.bug_id),
"fas_login": "testuser",
"milestone": str(milestone.id),
"blocker": "y",
"justification": "Test",
},
follow_redirects=False,
)
# Should stay on form page with error
assert response.status_code == 200
assert b"already proposed" in response.data or b"already" in response.data

View file

@ -0,0 +1,613 @@
"""End-to-end tests for real-time voting workflow via Forgejo webhooks
This module tests the complete voting workflow including:
- Webhook configuration in Forgejo
- Comment-based voting on discussion issues
- Vote parsing and tracking
- Issue description updates with vote summaries
- Database persistence of voting data
- Admin commands (AGREED, REJECTED)
"""
# pylint: disable=duplicate-code
import hashlib
import hmac
import json
import time
from typing import Dict, Any
import requests
from blockerbugs import app, db
from blockerbugs.models.bug import Bug
from testing.forgejo_container import ForgejoContainerConfig, ForgejoRepoConfig
def create_webhook_payload(
action: str,
issue_number: int,
comment_body: str,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
) -> Dict[str, Any]:
"""Create a Forgejo webhook payload for testing
Args:
action: Action type (e.g., 'created', 'edited')
issue_number: Issue number
comment_body: Comment body text
forgejo_container: Forgejo container configuration
forgejo_test_repo: Test repository information
Returns:
dict: Webhook payload
"""
return {
'action': action,
'issue': {
'number': issue_number,
'state': 'open',
'title': 'Test Bug 987654 - Test blocker discussion',
'body': 'Initial discussion for voting test',
},
'comment': {
'id': int(time.time()), # Unique comment ID
'body': comment_body,
'user': {
'login': forgejo_container.test_user,
},
},
'repository': {
'full_name': forgejo_test_repo.full_name,
},
}
def compute_webhook_signature(payload: bytes, secret: str) -> str:
"""Compute HMAC-SHA256 signature for webhook payload
Args:
payload: Raw webhook payload bytes
secret: Webhook secret
Returns:
str: Hex-encoded HMAC signature
"""
return hmac.new(
secret.encode('ascii'),
payload,
hashlib.sha256
).hexdigest()
class TestVotingWebhookE2E: # pylint: disable=too-many-locals
"""End-to-end tests for voting webhook workflow"""
def test_single_vote_via_webhook(
self,
test_bug_with_discussion: tuple[Bug, int],
_webhook_secret: str,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
) -> None:
"""
GIVEN a bug with a discussion issue
WHEN a user posts a voting comment via webhook
THEN:
1. Webhook is verified and accepted
2. Vote is parsed correctly
3. Issue description is updated with vote summary
4. Database is updated with vote data
"""
bug, issue_number = test_bug_with_discussion
# Create webhook payload with a vote
payload = create_webhook_payload(
action='created',
issue_number=issue_number,
comment_body='BetaBlocker +1',
forgejo_container=forgejo_container,
forgejo_test_repo=forgejo_test_repo,
)
payload_bytes = json.dumps(payload).encode('utf-8')
signature = compute_webhook_signature(payload_bytes, _webhook_secret)
# Post comment to Forgejo (simulating user action)
headers = {
'Authorization': f"token {forgejo_container.test_token}",
'Content-Type': 'application/json',
}
comment_url = (
f"{forgejo_container.api_url}repos/"
f"{forgejo_test_repo.full_name}/issues/{issue_number}/comments"
)
requests.post(
comment_url,
headers=headers,
json={'body': 'BetaBlocker +1'},
timeout=10,
)
# Send webhook to app
with app.test_client() as client:
response = client.post(
'/api/v0/webhook/forgejo',
data=payload_bytes,
headers={
'Content-Type': 'application/json',
'X-Forgejo-Event': 'issue_comment',
'X-Forgejo-Signature': signature,
},
)
# Verify webhook was accepted
assert response.status_code == 200
response_data = response.get_json()
assert 'successfully' in response_data['msg'].lower()
# Verify issue description was updated
issue_url = (
f"{forgejo_container.api_url}repos/"
f"{forgejo_test_repo.full_name}/issues/{issue_number}"
)
issue_response = requests.get(
issue_url,
headers=headers,
timeout=10,
)
issue_data = issue_response.json()
# Issue body should contain vote summary
assert 'betablocker' in issue_data['body'].lower()
assert '+1' in issue_data['body']
assert forgejo_container.test_user in issue_data['body']
# Verify database was updated
db.session.refresh(bug)
assert bug.votes is not None
votes_data = json.loads(bug.votes)
assert 'betablocker' in votes_data
assert forgejo_container.test_user in votes_data['betablocker']['+1']
def test_multiple_votes_from_different_users(
self,
test_bug_with_discussion: tuple[Bug, int],
_webhook_secret: str,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
) -> None:
"""
GIVEN a bug with a discussion issue
WHEN multiple users post votes via webhooks
THEN:
1. All votes are tracked correctly
2. Issue description shows all votes
3. Database contains all voting data
"""
bug, issue_number = test_bug_with_discussion
# Simulate votes from test_user
votes = [
('BetaBlocker +1', forgejo_container.test_user),
('FinalBlocker -1', forgejo_container.test_user),
]
headers = {
'Authorization': f"token {forgejo_container.test_token}",
'Content-Type': 'application/json',
}
with app.test_client() as client:
for vote_text, username in votes:
# Post comment to Forgejo
comment_url = (
f"{forgejo_container.api_url}repos/"
f"{forgejo_test_repo.full_name}/issues/{issue_number}/comments"
)
requests.post(
comment_url,
headers=headers,
json={'body': vote_text},
timeout=10,
)
# Create and send webhook
payload = create_webhook_payload(
action='created',
issue_number=issue_number,
comment_body=vote_text,
forgejo_container=forgejo_container,
forgejo_test_repo=forgejo_test_repo,
)
payload['comment']['user']['login'] = username
payload_bytes = json.dumps(payload).encode('utf-8')
signature = compute_webhook_signature(payload_bytes, _webhook_secret)
response = client.post(
'/api/v0/webhook/forgejo',
data=payload_bytes,
headers={
'Content-Type': 'application/json',
'X-Forgejo-Event': 'issue_comment',
'X-Forgejo-Signature': signature,
},
)
assert response.status_code == 200
# Verify all votes are in database
db.session.refresh(bug)
assert bug.votes is not None
votes_data = json.loads(bug.votes)
assert 'betablocker' in votes_data
assert 'finalblocker' in votes_data
assert username in votes_data['betablocker']['+1']
assert username in votes_data['finalblocker']['-1']
def test_admin_agreed_command(
self,
test_bug_with_discussion: tuple[Bug, int],
_webhook_secret: str,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
) -> None:
"""
GIVEN a bug with votes from users
WHEN an admin posts AGREED command via webhook
THEN:
1. Summary comment is posted
2. Issue is updated with final decision
3. Database reflects the decision
"""
_bug, issue_number = test_bug_with_discussion
headers = {
'Authorization': f"token {forgejo_container.test_token}",
'Content-Type': 'application/json',
}
with app.test_client() as client:
# First, add some votes
vote_payload = create_webhook_payload(
action='created',
issue_number=issue_number,
comment_body='BetaBlocker +1',
forgejo_container=forgejo_container,
forgejo_test_repo=forgejo_test_repo,
)
vote_payload_bytes = json.dumps(vote_payload).encode('utf-8')
vote_signature = compute_webhook_signature(
vote_payload_bytes, _webhook_secret
)
comment_url = (
f"{forgejo_container.api_url}repos/"
f"{forgejo_test_repo.full_name}/issues/{issue_number}/comments"
)
requests.post(
comment_url,
headers=headers,
json={'body': 'BetaBlocker +1'},
timeout=10,
)
client.post(
'/api/v0/webhook/forgejo',
data=vote_payload_bytes,
headers={
'Content-Type': 'application/json',
'X-Forgejo-Event': 'issue_comment',
'X-Forgejo-Signature': vote_signature,
},
)
# Now post AGREED command
agreed_payload = create_webhook_payload(
action='created',
issue_number=issue_number,
comment_body='AGREED AcceptedBetaBlocker',
forgejo_container=forgejo_container,
forgejo_test_repo=forgejo_test_repo,
)
agreed_payload_bytes = json.dumps(agreed_payload).encode('utf-8')
agreed_signature = compute_webhook_signature(
agreed_payload_bytes, _webhook_secret
)
requests.post(
comment_url,
headers=headers,
json={'body': 'AGREED AcceptedBetaBlocker'},
timeout=10,
)
response = client.post(
'/api/v0/webhook/forgejo',
data=agreed_payload_bytes,
headers={
'Content-Type': 'application/json',
'X-Forgejo-Event': 'issue_comment',
'X-Forgejo-Signature': agreed_signature,
},
)
assert response.status_code == 200
# Verify summary comment was posted
comments_url = (
f"{forgejo_container.api_url}repos/"
f"{forgejo_test_repo.full_name}/issues/{issue_number}/comments"
)
comments_response = requests.get(
comments_url,
headers=headers,
timeout=10,
)
comments = comments_response.json()
# Look for summary comment from bot
summary_found = False
for comment in comments:
if 'following votes have been closed' in comment['body'].lower():
summary_found = True
assert 'betablocker' in comment['body'].lower()
break
assert summary_found, "Summary comment should be posted after AGREED"
def test_invalid_signature_rejected(
self,
test_bug_with_discussion: tuple[Bug, int],
_webhook_secret: str,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
) -> None:
"""
GIVEN a webhook payload
WHEN the HMAC signature is invalid
THEN:
1. Webhook is rejected
2. No vote processing occurs
3. Database is not updated
"""
bug, issue_number = test_bug_with_discussion
payload = create_webhook_payload(
action='created',
issue_number=issue_number,
comment_body='BetaBlocker +1',
forgejo_container=forgejo_container,
forgejo_test_repo=forgejo_test_repo,
)
payload_bytes = json.dumps(payload).encode('utf-8')
# Use wrong signature
wrong_signature = 'wrong-signature-value'
with app.test_client() as client:
response = client.post(
'/api/v0/webhook/forgejo',
data=payload_bytes,
headers={
'Content-Type': 'application/json',
'X-Forgejo-Event': 'issue_comment',
'X-Forgejo-Signature': wrong_signature,
},
)
# Webhook should be rejected
assert response.status_code == 200 # Returns 200 but with rejection msg
response_data = response.get_json()
is_rejected = (
'invalid' in response_data['msg'].lower() or
'ignoring' in response_data['msg'].lower()
)
assert is_rejected
# Database should not be updated
db.session.refresh(bug)
assert bug.votes is None or bug.votes == 'null' or bug.votes == '{}'
def test_closed_issue_ignored(
self,
test_bug_with_discussion: tuple[Bug, int],
_webhook_secret: str,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
) -> None:
"""
GIVEN a closed discussion issue
WHEN a vote comment is posted via webhook
THEN:
1. Webhook acknowledges but ignores closed issue
2. No vote processing occurs
"""
_bug, issue_number = test_bug_with_discussion
# Create payload for closed issue
payload = create_webhook_payload(
action='created',
issue_number=issue_number,
comment_body='BetaBlocker +1',
forgejo_container=forgejo_container,
forgejo_test_repo=forgejo_test_repo,
)
payload['issue']['state'] = 'closed' # Mark as closed
payload_bytes = json.dumps(payload).encode('utf-8')
signature = compute_webhook_signature(payload_bytes, _webhook_secret)
with app.test_client() as client:
response = client.post(
'/api/v0/webhook/forgejo',
data=payload_bytes,
headers={
'Content-Type': 'application/json',
'X-Forgejo-Event': 'issue_comment',
'X-Forgejo-Signature': signature,
},
)
assert response.status_code == 200
response_data = response.get_json()
is_ignored = (
'closed' in response_data['msg'].lower() and
'ignoring' in response_data['msg'].lower()
)
assert is_ignored
def test_vote_update_via_edit(
self,
test_bug_with_discussion: tuple[Bug, int],
_webhook_secret: str,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
) -> None:
"""
GIVEN a user who posted a vote
WHEN the user edits their comment to change the vote
THEN:
1. Webhook processes the edit
2. Vote is updated correctly
3. Database reflects the new vote
"""
bug, issue_number = test_bug_with_discussion
headers = {
'Authorization': f"token {forgejo_container.test_token}",
'Content-Type': 'application/json',
}
with app.test_client() as client:
# Initial vote - post comment to Forgejo
comment_url = (
f"{forgejo_container.api_url}repos/"
f"{forgejo_test_repo.full_name}/issues/{issue_number}/comments"
)
comment_response = requests.post(
comment_url,
headers=headers,
json={'body': 'BetaBlocker +1'},
timeout=10,
)
comment_data = comment_response.json()
comment_id = comment_data['id']
# Send initial webhook
initial_payload = create_webhook_payload(
action='created',
issue_number=issue_number,
comment_body='BetaBlocker +1',
forgejo_container=forgejo_container,
forgejo_test_repo=forgejo_test_repo,
)
initial_payload['comment']['id'] = comment_id
initial_bytes = json.dumps(initial_payload).encode('utf-8')
initial_signature = compute_webhook_signature(initial_bytes, _webhook_secret)
client.post(
'/api/v0/webhook/forgejo',
data=initial_bytes,
headers={
'Content-Type': 'application/json',
'X-Forgejo-Event': 'issue_comment',
'X-Forgejo-Signature': initial_signature,
},
)
# Edit the comment in Forgejo to change the vote
edit_comment_url = (
f"{forgejo_container.api_url}repos/"
f"{forgejo_test_repo.full_name}/issues/comments/{comment_id}"
)
requests.patch(
edit_comment_url,
headers=headers,
json={'body': 'BetaBlocker -1'}, # Changed vote
timeout=10,
)
# Send webhook for the edit
edit_payload = create_webhook_payload(
action='edited',
issue_number=issue_number,
comment_body='BetaBlocker -1', # Changed vote
forgejo_container=forgejo_container,
forgejo_test_repo=forgejo_test_repo,
)
edit_payload['comment']['id'] = comment_id
edit_bytes = json.dumps(edit_payload).encode('utf-8')
edit_signature = compute_webhook_signature(edit_bytes, _webhook_secret)
response = client.post(
'/api/v0/webhook/forgejo',
data=edit_bytes,
headers={
'Content-Type': 'application/json',
'X-Forgejo-Event': 'issue_comment',
'X-Forgejo-Signature': edit_signature,
},
)
assert response.status_code == 200
# Verify vote was updated in database
db.session.refresh(bug)
votes_data = json.loads(bug.votes)
# User should now be in -1, not in +1
assert forgejo_container.test_user in votes_data['betablocker']['-1']
assert forgejo_container.test_user not in votes_data['betablocker']['+1']
def test_bot_disabled(
self,
test_bug_with_discussion: tuple[Bug, int],
_webhook_secret: str,
forgejo_container: ForgejoContainerConfig,
forgejo_test_repo: ForgejoRepoConfig,
) -> None:
"""
GIVEN Forgejo bot is disabled in config
WHEN a webhook is received
THEN:
1. Webhook is acknowledged but ignored
2. No vote processing occurs
"""
_bug, issue_number = test_bug_with_discussion
# Temporarily disable bot
original_enabled = app.config['FORGEJO_BOT_ENABLED']
app.config['FORGEJO_BOT_ENABLED'] = False
try:
payload = create_webhook_payload(
action='created',
issue_number=issue_number,
comment_body='BetaBlocker +1',
forgejo_container=forgejo_container,
forgejo_test_repo=forgejo_test_repo,
)
payload_bytes = json.dumps(payload).encode('utf-8')
signature = compute_webhook_signature(payload_bytes, _webhook_secret)
with app.test_client() as client:
response = client.post(
'/api/v0/webhook/forgejo',
data=payload_bytes,
headers={
'Content-Type': 'application/json',
'X-Forgejo-Event': 'issue_comment',
'X-Forgejo-Signature': signature,
},
)
assert response.status_code == 200
response_data = response.get_json()
assert 'disabled' in response_data['msg'].lower()
finally:
# Restore original setting
app.config['FORGEJO_BOT_ENABLED'] = original_enabled

View file

@ -0,0 +1,116 @@
# Copyright 2011, Red Hat, Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""
Forgejo container configuration for test infrastructure.
"""
import dataclasses
import logging
import time
from typing import Dict
import requests
LOGGER = logging.getLogger(__name__)
def is_forgejo_available(
host: str,
port: int,
timeout: int = 30,
interval: int = 1,
) -> bool:
"""
Poll the Forgejo API version endpoint until it responds.
Args:
host: Forgejo hostname
port: Forgejo port
timeout: Maximum seconds to wait (default: 30)
interval: Seconds between retries (default: 1)
Returns:
True if Forgejo is reachable, False if timeout exceeded
"""
url = f"http://{host}:{port}/api/v1/version"
retries = timeout // interval
for _ in range(retries):
try:
response = requests.get(url, timeout=2)
if response.status_code == 200:
LOGGER.info(
"Forgejo is available at %s:%s", host, port)
return True
except requests.exceptions.RequestException:
pass
time.sleep(interval)
LOGGER.debug(
"Forgejo not available at %s:%s after %ss", host, port, timeout)
return False
@dataclasses.dataclass
class ForgejoRepoConfig:
"""Test repository configuration."""
owner: str
name: str
full_name: str
@dataclasses.dataclass
class ForgejoApiClientConfig:
"""Authenticated Forgejo API client configuration."""
api_url: str
token: str
@property
def headers(self) -> Dict[str, str]:
"""HTTP headers for authenticated API requests."""
return {
'Authorization': f"token {self.token}",
'Content-Type': 'application/json',
}
@dataclasses.dataclass
class ForgejoContainerConfig: # pylint: disable=too-many-instance-attributes
"""Forgejo container connection and test account configuration."""
host: str
port: int
admin_user: str
admin_password: str
admin_email: str
admin_token: str
test_user: str
test_password: str
test_email: str
test_token: str
test_org: str
test_team: str
@property
def base_url(self) -> str:
"""Base URL to access Forgejo web interface."""
return f"http://{self.host}:{self.port}"
@property
def api_url(self) -> str:
"""Forgejo API base URL."""
return f"{self.base_url}/api/v1/"

View file

View file

@ -0,0 +1,349 @@
"""Integration tests for Forgejo testcontainer fixtures"""
import requests
from testing.forgejo_container import ForgejoApiClientConfig, ForgejoRepoConfig
from testing.forgejo_container import ForgejoContainerConfig
class TestForgejoContainerFixture:
"""Test the forgejo_container fixture"""
def test_container_is_running(self, forgejo_container: ForgejoContainerConfig) -> None:
"""
GIVEN a Forgejo testcontainer fixture
WHEN the container is initialized
THEN the container should be running and accessible with all required fields
"""
assert forgejo_container is not None
assert forgejo_container.base_url is not None
assert forgejo_container.api_url is not None
def test_forgejo_version_endpoint(self, forgejo_container: ForgejoContainerConfig) -> None:
"""
GIVEN a running Forgejo container
WHEN the version API endpoint is called
THEN it should return a 200 status with version information
"""
response = requests.get(f"{forgejo_container.api_url}version", timeout=5)
assert response.status_code == 200
data = response.json()
assert "version" in data
def test_admin_user_exists(self, forgejo_container: ForgejoContainerConfig) -> None:
"""
GIVEN a Forgejo container with initialization complete
WHEN the admin user configuration is checked
THEN all admin user credentials should be present and valid
"""
assert forgejo_container.admin_user is not None
assert forgejo_container.admin_password is not None
assert forgejo_container.admin_email is not None
assert forgejo_container.admin_user == "testadmin"
def test_admin_token_valid(self, forgejo_container: ForgejoContainerConfig) -> None:
"""
GIVEN a Forgejo container with an admin API token
WHEN the token is used to access the user info endpoint
THEN the request should succeed and return the admin user's information
"""
assert forgejo_container.admin_token is not None
assert len(forgejo_container.admin_token) > 0
# Test token by accessing user info endpoint
response = requests.get(
f"{forgejo_container.api_url}user",
headers={"Authorization": f"token {forgejo_container.admin_token}"},
timeout=5,
)
assert response.status_code == 200
user_data = response.json()
assert user_data["login"] == forgejo_container.admin_user
class TestForgejoApiClientFixture:
"""Test the forgejo_api_client fixture"""
def test_api_client_has_required_fields(
self, forgejo_api_client: ForgejoApiClientConfig
) -> None:
"""
GIVEN a Forgejo API client fixture
WHEN the fixture is accessed
THEN it should contain all required fields (api_url, token, headers)
"""
assert forgejo_api_client.api_url is not None
assert forgejo_api_client.token is not None
assert forgejo_api_client.headers is not None
def test_api_client_headers_valid(self, forgejo_api_client: ForgejoApiClientConfig) -> None:
"""
GIVEN a Forgejo API client fixture
WHEN the headers are inspected
THEN they should be properly formatted with Authorization and Content-Type
"""
headers = forgejo_api_client.headers
assert "Authorization" in headers
assert headers["Authorization"].startswith("token ")
assert "Content-Type" in headers
assert headers["Content-Type"] == "application/json"
def test_api_client_can_list_repos(self, forgejo_api_client: ForgejoApiClientConfig) -> None:
"""
GIVEN an authenticated Forgejo API client
WHEN a request is made to list repositories
THEN the request should succeed and return a list of repositories
"""
response = requests.get(
f"{forgejo_api_client.api_url}user/repos",
headers=forgejo_api_client.headers,
timeout=5,
)
assert response.status_code == 200
repos = response.json()
assert isinstance(repos, list)
class TestForgejoTestRepoFixture:
"""Test the forgejo_test_repo fixture"""
def test_repo_created(
self, forgejo_test_repo: ForgejoRepoConfig, forgejo_api_client: ForgejoApiClientConfig
) -> None:
"""
GIVEN a Forgejo test repository fixture
WHEN the repository is checked via API
THEN it should exist with the correct owner and name
"""
assert forgejo_test_repo.owner is not None
assert forgejo_test_repo.name is not None
assert forgejo_test_repo.full_name is not None
assert (
forgejo_test_repo.full_name
== f"{forgejo_test_repo.owner}/{forgejo_test_repo.name}"
)
# Verify repository exists via API
response = requests.get(
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}",
headers=forgejo_api_client.headers,
timeout=5,
)
assert response.status_code == 200
repo_data = response.json()
assert repo_data["name"] == forgejo_test_repo.name
def test_repo_name_is_unique(self, forgejo_test_repo: ForgejoRepoConfig) -> None:
"""
GIVEN a Forgejo test repository
WHEN the repository name is checked
THEN it should contain a unique identifier to avoid collisions
"""
assert forgejo_test_repo.name.startswith("test-repo-")
# Name should have UUID hex appended
assert len(forgejo_test_repo.name) > len("test-repo-")
def test_can_create_issue_in_repo(
self, forgejo_test_repo: ForgejoRepoConfig, forgejo_api_client: ForgejoApiClientConfig
) -> None:
"""
GIVEN a Forgejo test repository
WHEN an issue is created via API
THEN the issue should be created successfully with the correct title and body
"""
issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
issue_data = {
"title": "Test Issue",
"body": "This is a test issue created during integration testing",
}
response = requests.post(
issues_url,
headers=forgejo_api_client.headers,
json=issue_data,
timeout=5,
)
assert response.status_code == 201
created_issue = response.json()
assert created_issue["title"] == issue_data["title"]
assert created_issue["body"] == issue_data["body"]
assert created_issue["number"] == 1 # First issue
def test_can_list_issues_in_repo(
self, forgejo_test_repo: ForgejoRepoConfig, forgejo_api_client: ForgejoApiClientConfig
) -> None:
"""
GIVEN a Forgejo test repository
WHEN the issues endpoint is queried
THEN it should return a list of issues
"""
issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
response = requests.get(
issues_url,
headers=forgejo_api_client.headers,
timeout=5,
)
assert response.status_code == 200
issues = response.json()
assert isinstance(issues, list)
class TestForgejoIssueOperations:
"""Test common Forgejo issue operations using the fixtures"""
def test_create_and_get_issue(
self, forgejo_test_repo: ForgejoRepoConfig, forgejo_api_client: ForgejoApiClientConfig
) -> None:
"""
GIVEN a Forgejo test repository
WHEN an issue is created and then retrieved
THEN the retrieved issue should match the created issue
"""
base_issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# Create issue
create_response = requests.post(
base_issues_url,
headers=forgejo_api_client.headers,
json={"title": "Test Issue", "body": "Test body"},
timeout=5,
)
assert create_response.status_code == 201
issue = create_response.json()
issue_number = issue["number"]
# Get issue
get_response = requests.get(
f"{base_issues_url}/{issue_number}",
headers=forgejo_api_client.headers,
timeout=5,
)
assert get_response.status_code == 200
retrieved_issue = get_response.json()
assert retrieved_issue["number"] == issue_number
assert retrieved_issue["title"] == "Test Issue"
def test_create_issue_with_labels(
self, forgejo_test_repo: ForgejoRepoConfig, forgejo_api_client: ForgejoApiClientConfig
) -> None:
"""
GIVEN a Forgejo test repository with a label
WHEN an issue is created with that label
THEN the issue should be created with the label attached
"""
repo_base_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}"
# First create a label
label_response = requests.post(
f"{repo_base_url}/labels",
headers=forgejo_api_client.headers,
json={"name": "bug", "color": "#ff0000"},
timeout=5,
)
assert label_response.status_code == 201
label_id = label_response.json()["id"]
# Create issue with label
issue_response = requests.post(
f"{repo_base_url}/issues",
headers=forgejo_api_client.headers,
json={"title": "Bug Issue", "body": "Bug description", "labels": [label_id]},
timeout=5,
)
assert issue_response.status_code == 201
issue = issue_response.json()
assert len(issue["labels"]) > 0
def test_add_comment_to_issue(
self, forgejo_test_repo: ForgejoRepoConfig, forgejo_api_client: ForgejoApiClientConfig
) -> None:
"""
GIVEN a Forgejo issue
WHEN a comment is posted to the issue
THEN the comment should be added successfully with the correct body
"""
base_issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# Create issue
issue_response = requests.post(
base_issues_url,
headers=forgejo_api_client.headers,
json={"title": "Issue for comments", "body": "Test"},
timeout=5,
)
issue_number = issue_response.json()["number"]
# Add comment
comment_response = requests.post(
f"{base_issues_url}/{issue_number}/comments",
headers=forgejo_api_client.headers,
json={"body": "This is a test comment"},
timeout=5,
)
assert comment_response.status_code == 201
comment = comment_response.json()
assert comment["body"] == "This is a test comment"
def test_close_issue(
self, forgejo_test_repo: ForgejoRepoConfig, forgejo_api_client: ForgejoApiClientConfig
) -> None:
"""
GIVEN an open Forgejo issue
WHEN the issue is closed via API
THEN the issue state should be updated to closed
"""
base_issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# Create issue
issue_response = requests.post(
base_issues_url,
headers=forgejo_api_client.headers,
json={"title": "Issue to close", "body": "Test"},
timeout=5,
)
issue_number = issue_response.json()["number"]
# Close issue
close_response = requests.patch(
f"{base_issues_url}/{issue_number}",
headers=forgejo_api_client.headers,
json={"state": "closed"},
timeout=5,
)
assert close_response.status_code == 201
closed_issue = close_response.json()
assert closed_issue["state"] == "closed"
def test_update_issue_title(
self, forgejo_test_repo: ForgejoRepoConfig, forgejo_api_client: ForgejoApiClientConfig
) -> None:
"""
GIVEN an existing Forgejo issue
WHEN the issue title is updated via API
THEN the issue should reflect the new title
"""
base_issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# Create issue
issue_response = requests.post(
base_issues_url,
headers=forgejo_api_client.headers,
json={"title": "Original Title", "body": "Test"},
timeout=5,
)
issue_number = issue_response.json()["number"]
# Update title
update_response = requests.patch(
f"{base_issues_url}/{issue_number}",
headers=forgejo_api_client.headers,
json={"title": "Updated Title"},
timeout=5,
)
assert update_response.status_code == 201
updated_issue = update_response.json()
assert updated_issue["title"] == "Updated Title"

View file

@ -0,0 +1,170 @@
"""Integration tests for Forgejo bot using real Forgejo instance"""
# pylint: disable=duplicate-code
from typing import Generator
from unittest.mock import patch, MagicMock
import pytest
import requests
from blockerbugs.util import forgejo_bot
from blockerbugs.util.forgejo_interface import ForgejoInterface
from testing.forgejo_container import (
ForgejoApiClientConfig, ForgejoContainerConfig, ForgejoRepoConfig,
)
@pytest.fixture
def _forgejo_bot_config(
forgejo_container: ForgejoContainerConfig, forgejo_test_repo: ForgejoRepoConfig
) -> Generator[None, None, None]:
"""
GIVEN a running Forgejo container and test repository
WHEN setting up app configuration for bot tests
THEN all required config values are patched
"""
with patch("blockerbugs.util.forgejo_bot.app") as mock_app:
mock_app.config = {
"FORGEJO_API": forgejo_container.api_url,
"FORGEJO_BOT_ACCESS_TOKEN": forgejo_container.test_token,
"FORGEJO_REPO": forgejo_test_repo.full_name,
"FORGEJO_URL": forgejo_container.base_url + "/",
"FORGEJO_ADMIN_ORG": forgejo_container.test_org,
"FORGEJO_BOT_USERNAME": "testuser",
"FORGEJO_BOT_LOOP_THRESHOLD": 5,
"FORGEJO_DISCUSSION_TITLE": "Bug $bugid - $summary",
"FORGEJO_DISCUSSION_CONTENT": "Bug: $bug_url\n\n$vote_summary",
"BLOCKERBUGS_URL": "http://localhost/",
"BLOCKERBUGS_API": "http://localhost/api/",
}
mock_app.logger = MagicMock()
# Also patch forgejo_interface.app since ForgejoComment uses it
with patch("blockerbugs.util.forgejo_interface.app") as mock_interface_app:
mock_interface_app.config = mock_app.config
mock_interface_app.logger = MagicMock()
yield
class TestForgejoCommentIntegration:
"""Integration tests for ForgejoComment with real Forgejo API"""
def test_user_is_admin_positive(
self, _forgejo_bot_config: None, forgejo_container: ForgejoContainerConfig
) -> None:
"""
GIVEN a comment from a user who is a public org member
WHEN checking if the user is an admin
THEN True is returned
"""
forgejo = ForgejoInterface()
comment_data = {
"id": 1,
"body": "Test",
"user": {"login": forgejo_container.test_user},
}
comment = forgejo_bot.ForgejoComment(comment_data, forgejo)
assert comment.user_is_admin() is True
def test_user_is_admin_negative(
self, _forgejo_bot_config: None, forgejo_container: ForgejoContainerConfig
) -> None:
"""
GIVEN a comment from a user who is not a public org member
WHEN checking if the user is an admin
THEN False is returned
"""
forgejo = ForgejoInterface()
comment_data = {
"id": 1,
"body": "Test",
"user": {"login": forgejo_container.admin_user},
}
comment = forgejo_bot.ForgejoComment(comment_data, forgejo)
assert comment.user_is_admin() is False
class TestWebhookHandlerIntegration:
"""Integration tests for webhook_handler function"""
def test_webhook_handler_no_comments(
self,
_forgejo_bot_config: None,
forgejo_test_repo: ForgejoRepoConfig,
forgejo_api_client: ForgejoApiClientConfig,
) -> None:
"""
GIVEN a Forgejo issue with no comments
WHEN the webhook handler is called
THEN the handler completes gracefully with a warning
"""
issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
# Create an issue with no comments
response = requests.post(
issues_url,
headers=forgejo_api_client.headers,
json={"title": "Test Issue No Comments", "body": "Test body"},
timeout=5,
)
issue_number = response.json()["number"]
# Should handle gracefully with warning when no comments exist
# The handler will fetch real comments (empty list) and return early
forgejo_bot.webhook_handler(issue_number)
def test_webhook_handler_with_votes(
self,
_forgejo_bot_config: None,
forgejo_test_repo: ForgejoRepoConfig,
forgejo_api_client: ForgejoApiClientConfig,
) -> None:
"""
GIVEN a Forgejo issue with vote comments
WHEN the webhook handler is called
THEN votes are processed and real API calls are made
"""
base_issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# Create an issue
response = requests.post(
base_issues_url,
headers=forgejo_api_client.headers,
json={"title": "Test Issue for Voting", "body": "Test body"},
timeout=5,
)
issue_number = response.json()["number"]
# Add vote comments
comments_url = f"{base_issues_url}/{issue_number}/comments"
requests.post(
comments_url,
headers=forgejo_api_client.headers,
json={"body": "+1 betablocker"},
timeout=5,
)
# Mock database operations only - let Forgejo API calls happen for real
with patch("blockerbugs.util.forgejo_bot.db"):
with patch("blockerbugs.util.forgejo_bot.Milestone"):
with patch("blockerbugs.util.forgejo_bot.Bug"):
with patch("blockerbugs.util.forgejo_interface.issue_to_bug") as mock_itb:
# Create a mock bug with required attributes for template rendering
mock_bug = MagicMock()
mock_bug.bugid = 12345
mock_bug.summary = "Test bug summary"
mock_bug.url = "https://bugzilla.example.com/show_bug.cgi?id=12345"
mock_bug.component = "test-component"
mock_bug.milestone = MagicMock()
mock_bug.milestone.release = "test-release"
mock_itb.return_value = mock_bug
# Should process votes and make real API calls to Forgejo
forgejo_bot.webhook_handler(issue_number)

View file

@ -0,0 +1,420 @@
"""Integration tests for Forgejo interface using real Forgejo instance"""
# pylint: disable=duplicate-code
from typing import Generator
from unittest.mock import patch, MagicMock
import pytest
import requests
from blockerbugs.util.forgejo_interface import (
ForgejoInterface,
ForgejoAPIException,
BBValueError,
)
from testing.forgejo_container import (
ForgejoApiClientConfig, ForgejoContainerConfig, ForgejoRepoConfig,
)
@pytest.fixture(name="forgejo_interface")
def _forgejo_interface(
forgejo_container: ForgejoContainerConfig, forgejo_test_repo: ForgejoRepoConfig
) -> Generator[ForgejoInterface, None, None]:
"""
GIVEN a running Forgejo container and test repository
WHEN creating a ForgejoInterface instance
THEN the instance is configured with test settings
"""
with patch("blockerbugs.util.forgejo_interface.app") as mock_app:
mock_app.config = {
"FORGEJO_API": forgejo_container.api_url,
"FORGEJO_BOT_ACCESS_TOKEN": forgejo_container.test_token,
"FORGEJO_REPO": forgejo_test_repo.full_name,
"FORGEJO_URL": forgejo_container.base_url + "/",
}
mock_app.logger = MagicMock()
yield ForgejoInterface()
class TestForgejoInterfaceInitialization:
"""Test ForgejoInterface initialization and configuration"""
def test_missing_api_url(self) -> None:
"""
GIVEN config without FORGEJO_API setting
WHEN initializing ForgejoInterface
THEN a BBValueError is raised
"""
with patch("blockerbugs.util.forgejo_interface.app") as mock_app:
mock_app.config = {
"FORGEJO_API": None,
"FORGEJO_BOT_ACCESS_TOKEN": "token",
"FORGEJO_REPO": "owner/repo",
}
with pytest.raises(BBValueError, match="FORGEJO_API value invalid"):
ForgejoInterface()
def test_missing_token(self) -> None:
"""
GIVEN config without FORGEJO_BOT_ACCESS_TOKEN setting
WHEN initializing ForgejoInterface
THEN a BBValueError is raised
"""
with patch("blockerbugs.util.forgejo_interface.app") as mock_app:
mock_app.config = {
"FORGEJO_API": "http://localhost/api/v1/",
"FORGEJO_BOT_ACCESS_TOKEN": None,
"FORGEJO_REPO": "owner/repo",
}
with pytest.raises(
BBValueError, match="FORGEJO_BOT_ACCESS_TOKEN value invalid"
):
ForgejoInterface()
def test_invalid_repo_format(self) -> None:
"""
GIVEN config with invalid FORGEJO_REPO format
WHEN initializing ForgejoInterface
THEN a BBValueError is raised
"""
with patch("blockerbugs.util.forgejo_interface.app") as mock_app:
mock_app.config = {
"FORGEJO_API": "http://localhost/api/v1/",
"FORGEJO_BOT_ACCESS_TOKEN": "token",
"FORGEJO_REPO": "invalid-format", # Missing the slash
}
with pytest.raises(
BBValueError, match="FORGEJO_REPO value invalid"
):
ForgejoInterface()
def test_successful_initialization(
self, forgejo_container: ForgejoContainerConfig, forgejo_test_repo: ForgejoRepoConfig
) -> None:
"""
GIVEN valid configuration settings
WHEN initializing ForgejoInterface
THEN the instance is created with correct attributes
"""
with patch("blockerbugs.util.forgejo_interface.app") as mock_app:
mock_app.config = {
"FORGEJO_API": forgejo_container.api_url,
"FORGEJO_BOT_ACCESS_TOKEN": forgejo_container.test_token,
"FORGEJO_REPO": forgejo_test_repo.full_name,
}
mock_app.logger = MagicMock()
interface = ForgejoInterface()
assert interface.owner == forgejo_test_repo.owner
assert interface.repo == forgejo_test_repo.name
assert interface.client is not None
class TestForgejoInterfaceIntegration:
"""Integration tests for ForgejoInterface class"""
def test_create_issue(
self,
forgejo_interface: ForgejoInterface,
forgejo_test_repo: ForgejoRepoConfig,
forgejo_container: ForgejoContainerConfig,
) -> None:
"""
GIVEN a ForgejoInterface instance
WHEN creating an issue with title and content
THEN a valid issue URL is returned
"""
title = "Test Issue from Integration Test"
content = "This is a test issue created during integration testing"
issue_url = forgejo_interface.create_issue(title, content)
# Verify issue URL format
assert issue_url.startswith(forgejo_container.base_url)
assert forgejo_test_repo.full_name in issue_url
assert "/issues/" in issue_url
# Extract issue number from URL
issue_number = int(issue_url.split("/issues/")[-1])
assert issue_number > 0
def test_get_issue(
self,
forgejo_interface: ForgejoInterface,
forgejo_test_repo: ForgejoRepoConfig,
forgejo_api_client: ForgejoApiClientConfig,
) -> None:
"""
GIVEN an existing issue in Forgejo
WHEN retrieving the issue by number
THEN the issue details are returned correctly
"""
issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# First create an issue directly via API
response = requests.post(
issues_url,
headers=forgejo_api_client.headers,
json={"title": "Test Get Issue", "body": "Test body"},
timeout=5,
)
created_issue = response.json()
issue_number = created_issue["number"]
# Now get it via ForgejoInterface
issue = forgejo_interface.get_issue(issue_number)
assert issue["number"] == issue_number
assert issue["title"] == "Test Get Issue"
assert issue["body"] == "Test body"
assert issue["state"] == "open"
assert issue["user"]["login"] == "testuser"
def test_update_issue(
self,
forgejo_interface: ForgejoInterface,
forgejo_test_repo: ForgejoRepoConfig,
forgejo_api_client: ForgejoApiClientConfig,
) -> None:
"""
GIVEN an existing issue in Forgejo
WHEN updating the issue with new title and body
THEN the issue is updated successfully
"""
issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# First create an issue directly via API
response = requests.post(
issues_url,
headers=forgejo_api_client.headers,
json={"title": "Original Title", "body": "Original Body"},
timeout=5,
)
issue_number = response.json()["number"]
# Update the issue via ForgejoInterface
forgejo_interface.update_issue(
issue_number, title="Updated Title", body="Updated Body"
)
# Verify the update via ForgejoInterface
issue = forgejo_interface.get_issue(issue_number)
assert issue["title"] == "Updated Title"
assert issue["body"] == "Updated Body"
def test_close_issue(
self,
forgejo_interface: ForgejoInterface,
forgejo_test_repo: ForgejoRepoConfig,
forgejo_api_client: ForgejoApiClientConfig,
) -> None:
"""
GIVEN an open issue in Forgejo
WHEN closing the issue
THEN the issue state changes to closed
"""
issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# Create an issue first via direct API
response = requests.post(
issues_url,
headers=forgejo_api_client.headers,
json={"title": "Issue to Close", "body": "Will be closed"},
timeout=5,
)
issue_number = response.json()["number"]
# Close the issue via ForgejoInterface
forgejo_interface.close_issue(issue_number)
# Verify it's closed via ForgejoInterface
issue = forgejo_interface.get_issue(issue_number)
assert issue["state"] == "closed"
def test_post_comment(
self,
forgejo_interface: ForgejoInterface,
forgejo_test_repo: ForgejoRepoConfig,
forgejo_api_client: ForgejoApiClientConfig,
) -> None:
"""
GIVEN an existing issue in Forgejo
WHEN posting a comment to the issue
THEN the comment is created successfully
"""
issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# Create an issue first via direct API
response = requests.post(
issues_url,
headers=forgejo_api_client.headers,
json={"title": "Issue for Comment", "body": "Test"},
timeout=5,
)
issue_number = response.json()["number"]
# Post a comment via ForgejoInterface
comment_text = "This is a test comment from integration test"
forgejo_interface.post_comment(issue_number, comment_text)
# Verify the comment was posted via ForgejoInterface
comments = forgejo_interface.get_issue_comments(issue_number)
assert len(comments) == 1
assert comments[0]["body"] == comment_text
assert comments[0]["user"]["login"] == "testuser"
def test_get_issue_comments(
self,
forgejo_interface: ForgejoInterface,
forgejo_test_repo: ForgejoRepoConfig,
forgejo_api_client: ForgejoApiClientConfig,
) -> None:
"""
GIVEN an issue with multiple comments in Forgejo
WHEN retrieving the comments
THEN all comments are returned with correct data
"""
base_issues_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
)
# Create an issue first via direct API
response = requests.post(
base_issues_url,
headers=forgejo_api_client.headers,
json={"title": "Issue with Comments", "body": "Test"},
timeout=5,
)
issue_number = response.json()["number"]
# Post multiple comments via direct API
comments_text = ["First comment", "Second comment", "Third comment"]
comments_url = f"{base_issues_url}/{issue_number}/comments"
for comment in comments_text:
requests.post(
comments_url,
headers=forgejo_api_client.headers,
json={"body": comment},
timeout=5,
)
# Get comments via ForgejoInterface
comments = forgejo_interface.get_issue_comments(issue_number)
assert len(comments) == 3
for i, fetched_comment in enumerate(comments):
assert fetched_comment["body"] == comments_text[i]
assert fetched_comment["user"]["login"] == "testuser"
def test_is_org_public_member_positive(
self, forgejo_interface: ForgejoInterface, forgejo_container: ForgejoContainerConfig
) -> None:
"""
GIVEN a user who is a public member of an org
WHEN checking public org membership
THEN True is returned
"""
result = forgejo_interface.is_org_public_member(
forgejo_container.test_user,
forgejo_container.test_org,
)
assert result is True
def test_is_org_public_member_negative_wrong_user(
self, forgejo_interface: ForgejoInterface, forgejo_container: ForgejoContainerConfig
) -> None:
"""
GIVEN a user who is not a public member of an org
WHEN checking public org membership
THEN False is returned
"""
result = forgejo_interface.is_org_public_member(
forgejo_container.admin_user,
forgejo_container.test_org,
)
assert result is False
def test_is_org_public_member_negative_wrong_org(
self, forgejo_interface: ForgejoInterface, forgejo_container: ForgejoContainerConfig
) -> None:
"""
GIVEN a non-existent organization
WHEN checking public org membership
THEN False is returned
"""
result = forgejo_interface.is_org_public_member(
forgejo_container.test_user,
"nonexistent-org",
)
assert result is False
def test_create_issue_with_labels(
self,
forgejo_interface: ForgejoInterface,
forgejo_test_repo: ForgejoRepoConfig,
forgejo_api_client: ForgejoApiClientConfig,
) -> None:
"""
GIVEN a label created in the repository
WHEN creating an issue with that label
THEN the issue is created successfully
"""
repo_base_url = (
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}"
)
# First create a label via direct API
label_response = requests.post(
f"{repo_base_url}/labels",
headers=forgejo_api_client.headers,
json={"name": "bug", "color": "#ff0000"},
timeout=5,
)
label_id = label_response.json()["id"]
# Create issue with label via ForgejoInterface
issue_url = forgejo_interface.create_issue(
"Issue with Label", "This issue has a label", labels=[label_id]
)
# Verify the issue was created with the label via ForgejoInterface
issue_number = int(issue_url.split("/issues/")[-1])
issue = forgejo_interface.get_issue(issue_number)
# Note: get_issue doesn't return labels, so we verify the issue was created
assert issue["title"] == "Issue with Label"
def test_error_handling_invalid_issue(self, forgejo_interface: ForgejoInterface) -> None:
"""
GIVEN a non-existent issue number
WHEN attempting to get the issue
THEN a ForgejoAPIException is raised
"""
with pytest.raises(ForgejoAPIException) as exc_info:
forgejo_interface.get_issue(999999)
assert "Unable to get issue" in str(exc_info.value)
def test_error_handling_invalid_update(self, forgejo_interface: ForgejoInterface) -> None:
"""
GIVEN a non-existent issue number
WHEN attempting to update the issue
THEN a ForgejoAPIException is raised
"""
with pytest.raises(ForgejoAPIException) as exc_info:
forgejo_interface.update_issue(999999, title="New Title")
assert "Unable to update issue" in str(exc_info.value)
def test_error_handling_invalid_comment(self, forgejo_interface: ForgejoInterface) -> None:
"""
GIVEN a non-existent issue number
WHEN attempting to post a comment
THEN a ForgejoAPIException is raised
"""
with pytest.raises(ForgejoAPIException) as exc_info:
forgejo_interface.post_comment(999999, "Test comment")
assert "Unable to post comment" in str(exc_info.value)

View file

@ -0,0 +1,198 @@
"""Integration tests for blockerbugs.util.testdata using PostgreSQL testcontainer
This replicates the tests from testing/test_testdata.py but uses a real PostgreSQL
database running in a Docker container instead of an in-memory SQLite database.
"""
import datetime
import pytest
from blockerbugs import db
from blockerbugs.models.update import Update
from blockerbugs.models.release import Release
from blockerbugs.models.milestone import Milestone
from blockerbugs.models.bug import Bug
from blockerbugs.util import testdata
@pytest.mark.usefixtures('_postgres_db')
class TestTestDataPostgres:
"""Integration tests for testdata utility using PostgreSQL"""
def test_remove_empty(self):
"""
GIVEN an empty PostgreSQL database
WHEN removing non-existent test data
THEN the operation completes without errors
"""
testdata.remove_test_data()
def test_create(self):
"""
GIVEN an empty PostgreSQL database
WHEN creating test data
THEN all test objects are created with correct relationships
"""
testdata.create_test_data()
releases: list[Release] = Release.query.all()
assert len(releases) == 1
release = releases[0]
assert release.number == 101
milestones: list[Milestone] = Milestone.query.all()
assert len(milestones) > 0
for milestone in milestones:
assert milestone.release == release
updates: list[Update] = Update.query.all()
assert len(updates) > 0
for update in updates:
assert update.release == release
bugs: list[Bug] = Bug.query.all()
assert len(bugs) > 0
for bug in bugs:
assert bug.milestone in milestones
def test_create_and_remove(self):
"""
GIVEN an empty PostgreSQL database with test data created
WHEN removing the test data
THEN all test objects are deleted from the database
"""
testdata.create_test_data()
testdata.remove_test_data()
assert Release.query.count() == 0
assert Milestone.query.count() == 0
assert Update.query.count() == 0
assert Bug.query.count() == 0
def test_double_create(self):
"""
GIVEN a PostgreSQL database with existing test data
WHEN creating test data a second time
THEN the old data is removed and recreated with the same counts
"""
testdata.create_test_data()
num_releases = Release.query.count()
num_milestones = Milestone.query.count()
num_updates = Update.query.count()
num_bugs = Bug.query.count()
testdata.create_test_data()
assert Release.query.count() == num_releases
assert Milestone.query.count() == num_milestones
assert Update.query.count() == num_updates
assert Bug.query.count() == num_bugs
def test_not_touch_other_data(self):
"""
GIVEN a PostgreSQL database with both test data and real data
WHEN creating or removing test data
THEN real data remains unchanged
"""
release = Release(1, active=True)
db.session.add(release)
milestone = Milestone(release, 'beta', blocker_tracker=1, fe_tracker=2, name='1-beta',
active=True, current=True)
db.session.add(milestone)
bug = Bug(bugid=1, url=None, summary='bug', status='NEW', component='distro',
milestone=milestone, active=True, needinfo=False, needinfo_requestee=None)
db.session.add(bug)
update = Update(updateid='U1', release=release, status='testing', karma=0, url='url',
date_submitted=datetime.datetime.now(datetime.UTC))
db.session.add(update)
db.session.commit()
testdata.create_test_data()
# make sure the objects haven't changed
assert Release.query.filter_by(id=release.id).one() == release
assert Milestone.query.filter_by(id=milestone.id).one() == milestone
assert Bug.query.filter_by(id=bug.id).one() == bug
assert Update.query.filter_by(id=update.id).one() == update
testdata.remove_test_data()
# make sure the objects haven't changed
assert Release.query.filter_by(id=release.id).one() == release
assert Milestone.query.filter_by(id=milestone.id).one() == milestone
assert Bug.query.filter_by(id=bug.id).one() == bug
assert Update.query.filter_by(id=update.id).one() == update
def test_postgres_specific_features(self):
"""
GIVEN a PostgreSQL database with test data
WHEN verifying database dialect and transaction behavior
THEN PostgreSQL-specific features work correctly
"""
# Create test data
testdata.create_test_data()
# Verify we can use PostgreSQL-specific queries
# Check that the database dialect is PostgreSQL
assert db.engine.dialect.name == 'postgresql'
# Test that transactions work properly
# PostgreSQL has better transaction support than SQLite
release = Release.query.filter_by(number=101).one()
original_active = release.active
# Start a transaction that we'll roll back
release.active = not original_active
db.session.flush()
# Rollback the transaction
db.session.rollback()
# Verify the change was rolled back
db.session.expire_all()
release = Release.query.filter_by(number=101).one()
assert release.active == original_active
def test_concurrent_access_simulation(self):
"""
GIVEN a PostgreSQL database with existing test data
WHEN creating multiple objects without intermediate commits
THEN all objects are successfully created and committed in a single transaction
"""
# Create initial test data
testdata.create_test_data()
# Simulate multiple "concurrent" operations by creating multiple objects
# without committing in between
beta_milestone = Milestone.query.filter_by(version='beta').first()
# Create multiple bugs without intermediate commits
bugs = []
for i in range(1000, 1010):
bug = Bug(
bugid=i,
url=f'http://localhost/bug/{i}',
summary=f'Concurrent test bug {i}',
status='NEW',
component='test',
milestone=beta_milestone,
active=True,
needinfo=False,
needinfo_requestee=None
)
db.session.add(bug)
bugs.append(bug)
# Commit all at once
db.session.commit()
# Verify all bugs were created
for bug in bugs:
assert Bug.query.filter_by(bugid=bug.bugid).one() == bug
# Clean up
for bug in bugs:
db.session.delete(bug)
db.session.commit()

View file

@ -0,0 +1,218 @@
# Copyright 2011, Red Hat, Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""
Generic service container management for test infrastructure.
Provides a reusable abstraction for detecting, starting, and removing
named containers via the Docker SDK (docker-py). Service-specific
health checking is the caller's responsibility.
"""
import logging
import os
from pathlib import Path
import subprocess
from typing import Optional
import docker
import docker.errors
LOGGER = logging.getLogger(__name__)
def ensure_podman_environment() -> bool:
"""
Ensure the local podman environment is ready for testcontainers.
On systemd-based systems, starts the podman.socket service if it is
not already active and sets DOCKER_HOST to the podman socket path.
On non-systemd platforms (e.g., macOS with Podman Desktop), the
systemctl step is skipped. DOCKER_HOST is expected to be already
configured by the environment.
Always sets TESTCONTAINERS_RYUK_DISABLED for rootless podman.
Returns:
True if the environment is ready, False if setup failed
"""
# Start podman.socket if not already active (systemd platforms only)
try:
result = subprocess.run(
["systemctl", "--user", "is-active", "podman.socket"],
capture_output=True, # prevent output to terminal
check=False,
)
if result.returncode != 0:
try:
subprocess.run(
["systemctl", "--user", "start", "podman.socket"],
check=True,
capture_output=True, # prevent output to terminal
text=True, # return stdout and stderr as str object
)
LOGGER.info("Podman socket service started")
except subprocess.CalledProcessError as e:
LOGGER.error("Failed to start podman.socket: %s", e.stderr)
return False
except FileNotFoundError:
LOGGER.debug("systemctl not found, skipping podman.socket activation")
# Set DOCKER_HOST if not already set
if os.getenv("DOCKER_HOST") is None:
xdg_runtime_dir = os.getenv("XDG_RUNTIME_DIR")
if xdg_runtime_dir is None:
LOGGER.error(
"DOCKER_HOST is not set and XDG_RUNTIME_DIR is not defined; "
"cannot determine podman socket path"
)
return False
podman_socket_path = Path(xdg_runtime_dir) / "podman" / "podman.sock"
os.environ["DOCKER_HOST"] = f"unix://{podman_socket_path}"
# Ryuk is the testcontainers docker container state manager
# It must be disabled for rootless podman
os.environ["TESTCONTAINERS_RYUK_DISABLED"] = "true"
return True
class ServiceContainer:
"""
Manages a named container for test infrastructure.
Handles detecting, restarting, and removing containers via the
Docker SDK. The container ``name`` is used both as the local
container name and as the CI service hostname (Forgejo Actions
uses the service key as the DNS name).
Service-specific health checking is the caller's responsibility.
Args:
name: Container name, also used as CI service hostname
container_port: The port the service listens on inside the
container (e.g. 5432 for PostgreSQL)
ci_port: Host port exposed in CI. Defaults to ``container_port``
"""
def __init__(
self,
name: str,
container_port: int,
ci_port: Optional[int] = None,
):
self.name = name
self.container_port = container_port
self.ci_port = ci_port if ci_port is not None else container_port
def get_existing_port(self) -> Optional[int]: # pylint: disable=too-many-return-statements
"""
Find and return the host port of an existing container.
If the container exists but is stopped, it is restarted
automatically. The mapped host port for the configured
``container_port`` is extracted and returned.
Requires ``DOCKER_HOST`` to be set (done by
``ensure_podman_environment()``).
Returns:
The host port mapped to ``container_port``, or ``None``
if the container does not exist or its port mapping
cannot be determined.
"""
port_key = f"{self.container_port}/tcp"
try:
client = docker.from_env()
except docker.errors.DockerException:
LOGGER.debug("Failed to connect to container runtime")
return None
try:
container = client.containers.get(self.name)
# Restart stopped container
if container.status != "running":
LOGGER.info("Restarting stopped container '%s'", self.name)
try:
container.start()
container.reload()
except docker.errors.APIError as e:
LOGGER.debug(
"Failed to restart container '%s': %s",
self.name,
e,
)
return None
# Extract host port
ports = (
container.attrs.get("NetworkSettings", {})
.get("Ports", {})
.get(port_key, [])
)
if not ports:
LOGGER.debug(
"No port mapping found for %s on container '%s'",
port_key,
self.name,
)
return None
host_port = int(ports[0].get("HostPort", 0))
if host_port == 0:
return None
LOGGER.debug(
"Container '%s' is running with host port %s",
self.name,
host_port,
)
return host_port
except docker.errors.NotFound:
LOGGER.debug("Container '%s' not found", self.name)
return None
except (docker.errors.APIError, KeyError, IndexError, ValueError, TypeError):
LOGGER.debug(
"Failed to inspect container '%s'",
self.name,
)
return None
finally:
client.close()
def remove(self) -> None:
"""
Remove the container if it exists.
Uses ``force=True`` to stop and remove even running containers.
No error is raised if the container does not exist.
"""
try:
client = docker.from_env()
container = client.containers.get(self.name)
container.remove(force=True)
LOGGER.debug("Removed container '%s'", self.name)
client.close()
except docker.errors.NotFound:
pass # No container to remove
except docker.errors.DockerException as e:
LOGGER.debug("Failed to remove container '%s': %s", self.name, e)

View file

@ -15,10 +15,10 @@ from testing.test_controllers import add_release, add_milestone, \
from blockerbugs.controllers.api import api, errors
from blockerbugs.controllers.api.api import _get_bugtypes, _get_pretty_milestone_name, \
_UNKNOWN_BUG_SVG_TEXT, _BUG_CLOSED
from blockerbugs.util import pagure_bot
from blockerbugs.util import forgejo_bot
@pytest.mark.usefixtures('app_ctx', 'setup_teardown')
@pytest.mark.usefixtures('_postgres_db', 'setup_teardown')
class TestRestAPI:
@pytest.fixture
def setup_teardown(self):
@ -62,13 +62,11 @@ class TestRestAPI:
self.update_testing2 = add_update('test-testing2.fc99', self.release, 'testing', [bug2])
self.webhook_data = {
'msg': {
'issue': {
'id': 6666,
'status': 'Open',
},
'issue': {
'number': 6666,
'state': 'open',
},
'topic': 'issue.comment.added',
'action': 'created',
}
db.session.commit()
@ -240,70 +238,88 @@ class TestRestAPI:
# === /api/v0/webhook ===
def test_webhook_bot_disabled(self, monkeypatch):
url = '/api/v0/webhook'
url = '/api/v0/webhook/forgejo'
mock_webhook_handler = mock.MagicMock()
monkeypatch.setattr(pagure_bot, 'webhook_handler', mock_webhook_handler)
monkeypatch.setattr(api, 'check_signature', mock.MagicMock(return_value=True))
monkeypatch.setitem(app.config, 'PAGURE_BOT_ENABLED', False)
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
monkeypatch.setitem(app.config, 'FORGEJO_BOT_ENABLED', False)
resp = self.client.post(url, json=self.webhook_data)
headers = {'X-Forgejo-Event': 'issue_comment'}
resp = self.client.post(url, json=self.webhook_data, headers=headers)
assert resp.status_code == httplib.OK
respdata = json.loads(resp.data)
assert not mock_webhook_handler.called
assert respdata['msg'] == 'Pagure bot disabled, ignoring request'
assert respdata['msg'] == 'Forgejo bot disabled, ignoring request'
def test_webhook_bad_signature(self, monkeypatch):
# If we don't mock the signature, it doesn't match
url = '/api/v0/webhook'
url = '/api/v0/webhook/forgejo'
mock_webhook_handler = mock.MagicMock()
monkeypatch.setattr(pagure_bot, 'webhook_handler', mock_webhook_handler)
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
resp = self.client.post(url, json=self.webhook_data)
headers = {'X-Forgejo-Event': 'issue_comment'}
resp = self.client.post(url, json=self.webhook_data, headers=headers)
assert resp.status_code == httplib.OK
respdata = json.loads(resp.data)
print(respdata['msg'])
assert not mock_webhook_handler.called
assert respdata['msg'] == 'Wrong signature, ignoring.'
assert respdata['msg'] == 'Invalid signature, ignoring.'
def test_webhook_wrong_topic(self, monkeypatch):
url = '/api/v0/webhook'
url = '/api/v0/webhook/forgejo'
mock_webhook_handler = mock.MagicMock()
monkeypatch.setattr(pagure_bot, 'webhook_handler', mock_webhook_handler)
monkeypatch.setattr(api, 'check_signature', mock.MagicMock(return_value=True))
monkeypatch.setitem(self.webhook_data, 'topic', 'test.invalid.topic')
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
resp = self.client.post(url, json=self.webhook_data)
headers = {'X-Forgejo-Event': 'invalid_event'}
resp = self.client.post(url, json=self.webhook_data, headers=headers)
assert resp.status_code == httplib.OK
respdata = json.loads(resp.data)
assert not mock_webhook_handler.called
assert respdata['msg'].startswith('Ignoring message with topic')
assert respdata['msg'].startswith('Ignoring event:')
def test_webhook_wrong_action(self, monkeypatch):
url = '/api/v0/webhook/forgejo'
mock_webhook_handler = mock.MagicMock()
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
monkeypatch.setitem(self.webhook_data, 'action', 'deleted')
headers = {'X-Forgejo-Event': 'issue_comment'}
resp = self.client.post(url, json=self.webhook_data, headers=headers)
assert resp.status_code == httplib.OK
respdata = json.loads(resp.data)
assert not mock_webhook_handler.called
assert respdata['msg'].startswith('Ignoring issue_comment action:')
def test_webhook_missing_fields(self, monkeypatch):
url = '/api/v0/webhook'
url = '/api/v0/webhook/forgejo'
mock_webhook_handler = mock.MagicMock()
monkeypatch.setattr(pagure_bot, 'webhook_handler', mock_webhook_handler)
monkeypatch.setattr(api, 'check_signature', mock.MagicMock(return_value=True))
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
for field in ['id', 'status']:
headers = {'X-Forgejo-Event': 'issue_comment'}
for field in ['number', 'state']:
webhook_data = copy.deepcopy(self.webhook_data)
del webhook_data['msg']['issue'][field]
del webhook_data['issue'][field]
resp = self.client.post(url, json=webhook_data)
resp = self.client.post(url, json=webhook_data, headers=headers)
assert resp.status_code == httplib.OK
respdata = json.loads(resp.data)