Switch from Pagure to Forge for blocker review discussions
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:
parent
112db31ca1
commit
5a6f2b5736
55 changed files with 7793 additions and 2371 deletions
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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):
|
||||

|
||||
#### 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ##
|
||||
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
572
blockerbugs/util/forgejo_bot.py
Normal file
572
blockerbugs/util/forgejo_bot.py
Normal 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()
|
||||
453
blockerbugs/util/forgejo_interface.py
Normal file
453
blockerbugs/util/forgejo_interface.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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/"
|
||||
|
|
|
|||
89
docs/source/blockerbugs-workflow.md
Normal file
89
docs/source/blockerbugs-workflow.md
Normal 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.
|
||||
|
|
@ -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``.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
--------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
==================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
35
docs/source/usage.md
Normal 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
27
pyproject.toml
Normal 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"
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ munch
|
|||
psycopg[binary,pool]
|
||||
py
|
||||
pycurl
|
||||
pyforgejo
|
||||
pytest-cov
|
||||
pytest
|
||||
# bugzilla 3.2.0 because of API auth changes:
|
||||
|
|
|
|||
16
setup.cfg
16
setup.cfg
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
78
testing/db_container.py
Normal 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
0
testing/e2e/__init__.py
Normal file
317
testing/e2e/conftest.py
Normal file
317
testing/e2e/conftest.py
Normal 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
|
||||
423
testing/e2e/test_propose_bug.py
Normal file
423
testing/e2e/test_propose_bug.py
Normal 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
|
||||
613
testing/e2e/test_voting_webhook.py
Normal file
613
testing/e2e/test_voting_webhook.py
Normal 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
|
||||
116
testing/forgejo_container.py
Normal file
116
testing/forgejo_container.py
Normal 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/"
|
||||
0
testing/integration/__init__.py
Normal file
0
testing/integration/__init__.py
Normal file
349
testing/integration/test_fixtures.py
Normal file
349
testing/integration/test_fixtures.py
Normal 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"
|
||||
170
testing/integration/test_forgejo_bot.py
Normal file
170
testing/integration/test_forgejo_bot.py
Normal 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)
|
||||
420
testing/integration/test_forgejo_interface.py
Normal file
420
testing/integration/test_forgejo_interface.py
Normal 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)
|
||||
198
testing/integration/test_testdata_postgres.py
Normal file
198
testing/integration/test_testdata_postgres.py
Normal 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()
|
||||
218
testing/service_container.py
Normal file
218
testing/service_container.py
Normal 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)
|
||||
|
|
@ -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)
|
||||