From 9412ab9a72991d541e9247ed521e33de6c8bf414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Forr=C3=B3?= Date: Thu, 19 Feb 2026 16:05:08 +0100 Subject: [PATCH 1/3] Add webhook for dist-git onboarding requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nikola Forró --- packit_service/config.py | 7 +- packit_service/schema.py | 1 + packit_service/service/api/__init__.py | 2 + packit_service/service/api/onboarding.py | 81 ++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 packit_service/service/api/onboarding.py diff --git a/packit_service/config.py b/packit_service/config.py index d54491deb..de2a474bd 100644 --- a/packit_service/config.py +++ b/packit_service/config.py @@ -114,6 +114,7 @@ def __init__( logdetective_enabled: bool = False, logdetective_url: str = LOGDETECTIVE_PACKIT_SERVER_URL, logdetective_secret: str = "", + onboarding_secret: str = "", **kwargs, ): if "authentication" in kwargs: @@ -215,6 +216,9 @@ def __init__( # Token to be used with Log Detective interface server self.logdetective_secret = logdetective_secret + # Secret for dist-git onboarding requests + self.onboarding_secret = onboarding_secret + service_config = None def __repr__(self): @@ -248,7 +252,8 @@ def hide(token: str) -> str: f"logdetective_url='{self.logdetective_url}')" f"fedora_ci_run_by_default='{self.fedora_ci_run_by_default}', " f"enabled_projects_for_fedora_ci='{self.enabled_projects_for_fedora_ci}', " - f"disabled_projects_for_fedora_ci='{self.disabled_projects_for_fedora_ci}')" + f"disabled_projects_for_fedora_ci='{self.disabled_projects_for_fedora_ci}', " + f"onboarding_secret='{hide(self.onboarding_secret)}')" ) @classmethod diff --git a/packit_service/schema.py b/packit_service/schema.py index f5c94faf8..96c524c66 100644 --- a/packit_service/schema.py +++ b/packit_service/schema.py @@ -90,6 +90,7 @@ class ServiceConfigSchema(UserConfigSchema): logdetective_enabled = fields.Bool(missing=False, default=False) logdetective_url = fields.String() logdetective_secret = fields.String() + onboarding_secret = fields.String() @post_load def make_instance(self, data, **kwargs): diff --git a/packit_service/service/api/__init__.py b/packit_service/service/api/__init__.py index b1e345e09..862b8dd36 100644 --- a/packit_service/service/api/__init__.py +++ b/packit_service/service/api/__init__.py @@ -11,6 +11,7 @@ from packit_service.service.api.installations import ns as installations_ns from packit_service.service.api.koji_builds import koji_builds_ns from packit_service.service.api.koji_tag_requests import koji_tag_requests_ns +from packit_service.service.api.onboarding import ns as onboarding_ns from packit_service.service.api.osh_scans import ns as osh_scans_ns from packit_service.service.api.projects import ns as projects_ns from packit_service.service.api.propose_downstream import ns as propose_downstream_ns @@ -48,3 +49,4 @@ api.add_namespace(system_ns) api.add_namespace(bodhi_updates_ns) api.add_namespace(osh_scans_ns) +api.add_namespace(onboarding_ns) diff --git a/packit_service/service/api/onboarding.py b/packit_service/service/api/onboarding.py new file mode 100644 index 000000000..cad6e31f4 --- /dev/null +++ b/packit_service/service/api/onboarding.py @@ -0,0 +1,81 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +import logging +from http import HTTPStatus +from os import getenv + +from flask import request +from flask_restx import Namespace, Resource, fields + +from packit_service.celerizer import celery_app +from packit_service.config import ServiceConfig +from packit_service.constants import CELERY_DEFAULT_MAIN_TASK_NAME +from packit_service.service.api.errors import ValidationFailed + +logger = logging.getLogger("packit_service") + +config = ServiceConfig.get_service_config() + +ns = Namespace("onboarding", description="Packit dist-git onboarding") + +payload = ns.model( + "Packit dist-git onboarding request", + { + "package": fields.String(required=True, example="packit"), + "open_pr": fields.Boolean(required=True, default=True), + "token": fields.String(required=True, example="HERE-IS-A-VALID-TOKEN"), + }, +) + + +@ns.route("/request") +class OnboardingRequest(Resource): + @ns.response(HTTPStatus.OK.value, "Request has been accepted") + @ns.response(HTTPStatus.BAD_REQUEST.value, "Bad request data") + @ns.response(HTTPStatus.UNAUTHORIZED.value, "Secret validation failed") + @ns.expect(payload) + def post(self): + msg = request.json + + if not msg: + logger.debug("/onboarding/request: we haven't received any JSON data.") + return "We haven't received any JSON data.", HTTPStatus.BAD_REQUEST + + try: + self.validate_onboarding_request() + except ValidationFailed as exc: + logger.info(f"/onboarding/request {exc}") + return str(exc), HTTPStatus.UNAUTHORIZED + + msg["source"] = "onboarding" + + celery_app.send_task( + name=getenv("CELERY_MAIN_TASK_NAME") or CELERY_DEFAULT_MAIN_TASK_NAME, + kwargs={ + "event": msg, + "source": "onboarding", + "event_type": "request", + }, + ) + + return "Onboarding request accepted", HTTPStatus.OK + + @staticmethod + def validate_onboarding_request(): + if not config.onboarding_secret: + msg = "Onboarding secret not specified in config" + logger.error(msg) + raise ValidationFailed(msg) + + if not (token := request.json.get("token")): + msg = "The request doesn't contain any token" + logger.info(msg) + raise ValidationFailed(msg) + + if token == config.onboarding_secret: + return + + msg = "Invalid onboarding secret provided" + logger.warning(msg) + raise ValidationFailed(msg) From 4af02b4eae6495e79226a91acc90e7df15542314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Forr=C3=B3?= Date: Thu, 19 Feb 2026 17:49:55 +0100 Subject: [PATCH 2/3] Process dist-git onboarding requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nikola Forró --- packit_service/constants.py | 63 ++++++++++++ packit_service/events/onboarding.py | 25 +++++ packit_service/worker/checker/onboarding.py | 18 ++++ packit_service/worker/handlers/abstract.py | 1 + packit_service/worker/handlers/onboarding.py | 103 +++++++++++++++++++ packit_service/worker/jobs.py | 12 +++ packit_service/worker/parser.py | 20 ++++ packit_service/worker/tasks.py | 15 +++ 8 files changed, 257 insertions(+) create mode 100644 packit_service/events/onboarding.py create mode 100644 packit_service/worker/checker/onboarding.py create mode 100644 packit_service/worker/handlers/onboarding.py diff --git a/packit_service/constants.py b/packit_service/constants.py index 8a5cc917b..5c551280f 100644 --- a/packit_service/constants.py +++ b/packit_service/constants.py @@ -342,6 +342,69 @@ def from_number(number: int): # Default URL of the logdetective-packit interface server for sending the Log Detective requests. LOGDETECTIVE_PACKIT_SERVER_URL = "https://logdetective01.fedorainfracloud.org" +DG_ONBOARDING_TITLE = "Add initial Packit configuration" +DG_ONBOARDING_DESCRIPTION = """ +Hello, + +thank you for introducing a new package to Fedora! + +Let us present you [Packit](https://packit.dev/), an automation for Fedora releases. +We are sending a configuration file with a basic setup. +The automation will be enabled by merging this pull-request into the `rawhide` branch. + +If you have any question or concern, ask here or on `#packit:fedora.im` Matrix channel +and Packit team will help. + +If you look at the configuration file, you can see that Packit is configured to do 3 jobs for you: +* [`pull_from_upstream`](https://packit.dev/docs/configuration/downstream/pull_from_upstream): + Create a new set of pull-requests when there is a new upstream release. + (The specfile-changes and sources are taken care of. Packit is notified about new release from + [Release Monitoring](https://release-monitoring.org/) service. Check if your project is there.) +* [`koji_build`](https://packit.dev/docs/configuration/downstream/koji_build): + Submit a Koji build as reaction to a merged pull request. +* [`bodhi_update`](https://packit.dev/docs/configuration/downstream/bodhi_update): + Create a Bodhi update as a reaction to a succesful Koji build. + +These jobs are independent so you can pick just those that are relevant to you. +For each, you can also configure Fedora/EPEL versions other than Rawhide that the jobs +should be run for. + +You can also further tweak the process. A few handy options are prepared for you +in the confguration file to uncomment. Rest can be found in +[the documentation](https://packit.dev/docs/fedora-releases-guide/dist-git-onboarding). +In case you have a group of dependent packages, you might want to take a look at +[how to configure multi-package updates] +(https://packit.dev/docs/fedora-releases-guide/releasing-multiple-packages). + + +Things you still need to be aware of: + +* The package maintenance is still your responsibility -- Packit is just a handy tool that can + save you some time. +* When Packit introduces new releases in form of the pull-request, it's your responsibility + to check the pull-request including the newly-introduced source. This is the place where human + intervention is required. +* Be aware that there are other packages and packagers and that you might break someone else's work + by using Packit in a wrong way. (E.g. be careful about dependent packages since there is + no automatic check for these in place.) +* Check [Fedora updates policy](https://docs.fedoraproject.org/en-US/fesco/Updates_Policy/). +* Check [Fedora Packaging guidelines](https://docs.fedoraproject.org/en-US/packaging-guidelines/) + including the specifics for your package type. +* Consult the approach with other maintainers of this package and care about the Packit results + so you don't introduce spam and extra work for others. +* Speaking of notifications -- you might want to setup a rule on + [Fedora Notifications](https://notifications.fedoraproject.org/) so you won't miss + anything important, since `packit` FAS account will be the actor of the jobs. + + +In case you don't want to receive these pull-requests in the future, you can use +the `--onboard-packit no` option when running `fedpkg request-repo`. + + +I hope you will be happy with the automation! +*[Packit team](https://packit.dev/#contact)* +""" + # CI Transition comment for Fedora dist-git PRs # TODO: Remove this after March 2026 # https://github.com/packit/packit-service/issues/3008 diff --git a/packit_service/events/onboarding.py b/packit_service/events/onboarding.py new file mode 100644 index 000000000..cd98439cd --- /dev/null +++ b/packit_service/events/onboarding.py @@ -0,0 +1,25 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +from typing import Optional + +from packit.config import PackageConfig + +from .abstract.base import ForgeIndependent + + +class Request(ForgeIndependent): + @classmethod + def event_type(cls) -> str: + return "onboarding.Request" + + def __init__( + self, + package: str, + open_pr: bool = True, + ): + super().__init__(project_url=f"https://src.fedoraproject.org/rpms/{package}") + self.open_pr = open_pr + + def get_packages_config(self) -> Optional[PackageConfig]: + return None diff --git a/packit_service/worker/checker/onboarding.py b/packit_service/worker/checker/onboarding.py new file mode 100644 index 000000000..5a9023faf --- /dev/null +++ b/packit_service/worker/checker/onboarding.py @@ -0,0 +1,18 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +import logging + +from packit.constants import CONFIG_FILE_NAMES + +from packit_service.worker.checker.abstract import Checker + +logger = logging.getLogger(__name__) + + +class ProjectIsNotOnboarded(Checker): + def pre_check(self) -> bool: + if any(f for f in self.project.get_files(ref="rawhide") if f in CONFIG_FILE_NAMES): + logger.info(f"Package {self.project.repo} is already onboarded") + return False + return True diff --git a/packit_service/worker/handlers/abstract.py b/packit_service/worker/handlers/abstract.py index 4af23facb..851130450 100644 --- a/packit_service/worker/handlers/abstract.py +++ b/packit_service/worker/handlers/abstract.py @@ -265,6 +265,7 @@ class TaskName(str, enum.Enum): tag_into_sidetag = "task.tag_into_sidetag" openscanhub_task_finished = "task.openscanhub_task_finished" openscanhub_task_started = "task.openscanhub_task_started" + onboarding_request = "task.run_onboarding_request_handler" downstream_koji_scratch_build = "task.run_downstream_koji_scratch_build_handler" downstream_koji_scratch_build_report = "task.run_downstream_koji_scratch_build_report_handler" downstream_koji_eln_scratch_build = "task.run_downstream_koji_eln_scratch_build_handler" diff --git a/packit_service/worker/handlers/onboarding.py b/packit_service/worker/handlers/onboarding.py new file mode 100644 index 000000000..815335253 --- /dev/null +++ b/packit_service/worker/handlers/onboarding.py @@ -0,0 +1,103 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +""" +This file defines classes for job handlers specific to onboarding tasks +""" + +import logging + +from packit.cli.dist_git_init import ( + COMMIT_MESSAGE, + CONFIG_FILE_NAME, + ONBOARD_BRANCH_NAME, + DistGitInitializer, +) +from packit.config.config import Config +from packit.config.package_config import PackageConfig + +from packit_service.constants import DG_ONBOARDING_DESCRIPTION, DG_ONBOARDING_TITLE +from packit_service.events import ( + onboarding, +) +from packit_service.worker.checker.abstract import Checker +from packit_service.worker.checker.onboarding import ProjectIsNotOnboarded +from packit_service.worker.handlers.abstract import ( + JobHandler, + TaskName, + reacts_to, +) +from packit_service.worker.mixin import ConfigFromEventMixin, PackitAPIWithDownstreamMixin +from packit_service.worker.result import TaskResults + +logger = logging.getLogger(__name__) + + +@reacts_to(event=onboarding.Request) +class OnboardingRequestHandler( + JobHandler, + ConfigFromEventMixin, + PackitAPIWithDownstreamMixin, +): + task_name = TaskName.onboarding_request + + @staticmethod + def get_checkers() -> tuple[type[Checker], ...]: + return (ProjectIsNotOnboarded,) + + def _run(self) -> TaskResults: + package = self.project.repo + logger.debug(f"Running onboarding for {package}") + + # generate and load config + initializer = DistGitInitializer( + config=Config(), + path_or_url="", + upstream_git_url=None, + ) + self.package_config = self.job_config = PackageConfig.get_from_dict( + initializer.package_config_dict | {"downstream_package_name": package}, + ) + + self.perform_onboarding( + config=initializer.package_config_content, + open_pr=self.data.event_dict.get("open_pr", True), + ) + + return TaskResults(success=True, details={}) + + def perform_onboarding(self, config: str, open_pr: bool) -> None: + # clone the repo and fetch rawhide + self.packit_api.dg.create_branch( + "rawhide", + base="remotes/origin/rawhide", + setup_tracking=True, + ) + self.packit_api.dg.update_branch("rawhide") + self.packit_api.dg.switch_branch("rawhide", force=True) + + if open_pr: + self.packit_api.dg.create_branch(ONBOARD_BRANCH_NAME) + self.packit_api.dg.switch_branch(ONBOARD_BRANCH_NAME, force=True) + self.packit_api.dg.reset_workdir() + + working_dir = self.packit_api.dg.local_project.working_dir + + # create config file + (working_dir / CONFIG_FILE_NAME).write_text(config) + + self.packit_api.dg.commit( + title=COMMIT_MESSAGE, + msg="", + prefix="", + ) + + if open_pr: + self.packit_api.push_and_create_pr( + pr_title=DG_ONBOARDING_TITLE, + pr_description=DG_ONBOARDING_DESCRIPTION, + git_branch="rawhide", + repo=self.packit_api.dg, + ) + else: + self.packit_api.dg.push(refspec="HEAD:rawhide") diff --git a/packit_service/worker/jobs.py b/packit_service/worker/jobs.py index 540824dba..b0e5eba9e 100644 --- a/packit_service/worker/jobs.py +++ b/packit_service/worker/jobs.py @@ -40,6 +40,7 @@ github, koji, logdetective, + onboarding, pagure, testing_farm, ) @@ -83,6 +84,7 @@ RetriggerDownstreamKojiBuildHandler, TagIntoSidetagHandler, ) +from packit_service.worker.handlers.onboarding import OnboardingRequestHandler from packit_service.worker.helpers.build import ( BaseBuildJobHelper, CoprBuildJobHelper, @@ -358,6 +360,16 @@ def process(self) -> list[TaskResults]: ).apply_async() # should we comment about not processing if the comment is not # on the issue created by us or not in packit/notifications? + elif isinstance(self.event, onboarding.Request): + if OnboardingRequestHandler.pre_check( + package_config=None, + job_config=None, + event=self.event.get_dict(), + ): + OnboardingRequestHandler.get_signature( + event=self.event, + job=None, + ).apply_async() else: if ( isinstance( diff --git a/packit_service/worker/parser.py b/packit_service/worker/parser.py index 7128e5057..ae92df942 100644 --- a/packit_service/worker/parser.py +++ b/packit_service/worker/parser.py @@ -30,6 +30,7 @@ gitlab, koji, logdetective, + onboarding, openscanhub, pagure, testing_farm, @@ -134,6 +135,7 @@ def parse_event( koji.result.Task, openscanhub.task.Finished, openscanhub.task.Started, + onboarding.Request, pagure.pr.Comment, pagure.pr.Flag, pagure.pr.Action, @@ -189,6 +191,7 @@ def parse_event( Parser.parse_anitya_version_update_event, Parser.parse_openscanhub_task_finished_event, Parser.parse_openscanhub_task_started_event, + Parser.parse_onboarding_request_event, Parser.parse_commit_comment_event, Parser.parse_pagure_pull_request_event, Parser.parse_logdetective_analysis_event, @@ -1560,6 +1563,23 @@ def parse_koji_build_tag_event(event) -> Optional[koji.tag.Build]: owner=owner, ) + @staticmethod + def parse_onboarding_request_event( + event: dict, + ) -> Optional[onboarding.Request]: + if event.get("source") != "onboarding" or not event.get("package"): + return None + + package: str = event["package"] + logger.info(f"dist-git onboarding request event for package {package}") + + open_pr = bool(event.get("open_pr", True)) + + return onboarding.Request( + package=package, + open_pr=open_pr, + ) + @staticmethod def parse_pipeline_event(event) -> Optional[gitlab.pipeline.Pipeline]: """ diff --git a/packit_service/worker/tasks.py b/packit_service/worker/tasks.py index 236a098a9..7efa726e0 100644 --- a/packit_service/worker/tasks.py +++ b/packit_service/worker/tasks.py @@ -89,6 +89,7 @@ KojiBuildTagHandler, KojiTaskReportDownstreamHandler, ) +from packit_service.worker.handlers.onboarding import OnboardingRequestHandler from packit_service.worker.handlers.usage import check_onboarded_projects from packit_service.worker.helpers.build.babysit import ( check_copr_build, @@ -812,6 +813,20 @@ def run_downstream_log_detective_results_handler( return get_handlers_task_results(handler.run_job(), event) +@celery_app.task(name=TaskName.onboarding_request, base=TaskWithRetry) +def run_onboarding_request_handler( + event: dict, + package_config: dict, + job_config: dict, +): + handler = OnboardingRequestHandler( + package_config=load_package_config(package_config), + job_config=load_job_config(job_config), + event=event, + ) + return get_handlers_task_results(handler.run_job(), event) + + def get_handlers_task_results(results: dict, event: dict) -> dict: # include original event to provide more info return {"job": results, "event": event} From 1549ed608882fdedf78faa2c65d1f63e162c43aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Forr=C3=B3?= Date: Tue, 24 Feb 2026 08:49:16 +0100 Subject: [PATCH 3/3] Add a test for onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nikola Forró --- tests/data/webhooks/onboarding/request.json | 5 ++ tests/integration/test_onboarding.py | 92 +++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 tests/data/webhooks/onboarding/request.json create mode 100644 tests/integration/test_onboarding.py diff --git a/tests/data/webhooks/onboarding/request.json b/tests/data/webhooks/onboarding/request.json new file mode 100644 index 000000000..5fdacf7a4 --- /dev/null +++ b/tests/data/webhooks/onboarding/request.json @@ -0,0 +1,5 @@ +{ + "package": "packit", + "token": "secret-token", + "source": "onboarding" +} diff --git a/tests/integration/test_onboarding.py b/tests/integration/test_onboarding.py new file mode 100644 index 000000000..0fce9a62c --- /dev/null +++ b/tests/integration/test_onboarding.py @@ -0,0 +1,92 @@ +# Copyright Contributors to the Packit project. +# SPDX-License-Identifier: MIT + +import json + +import pytest +from celery.canvas import Signature +from flexmock import flexmock +from ogr.services.pagure import PagureProject +from packit.api import PackitAPI +from packit.config import Deployment + +from packit_service.config import ServiceConfig +from packit_service.worker.jobs import SteveJobs +from packit_service.worker.monitoring import Pushgateway +from packit_service.worker.tasks import ( + run_onboarding_request_handler, +) +from tests.spellbook import DATA_DIR, first_dict_value, get_parameters_from_results + + +@pytest.fixture() +def onboarding_request_event(): + return json.loads((DATA_DIR / "webhooks" / "onboarding" / "request.json").read_text()) + + +def test_onboarding(onboarding_request_event, tmp_path): + dg_project = ( + flexmock(PagureProject(namespace="rpms", repo="packit", service=flexmock(read_only=False))) + .should_receive("is_private") + .and_return(False) + .mock() + .should_receive("get_files") + .and_return(["packit.spec", "sources"]) + .mock() + ) + service_config = ( + flexmock(deployment=Deployment.stg) + .should_receive("get_project") + .and_return(dg_project) + .mock() + ) + flexmock(ServiceConfig).should_receive("get_service_config").and_return(service_config) + + flexmock(PackitAPI).should_receive("init_kerberos_ticket") + + git_repo = tmp_path / "dist-git" + git_repo.mkdir() + + dg = ( + flexmock(local_project=flexmock(working_dir=git_repo)) + .should_receive("create_branch") + .twice() + .mock() + .should_receive("update_branch") + .once() + .mock() + .should_receive("switch_branch") + .twice() + .mock() + .should_receive("reset_workdir") + .once() + .mock() + .should_receive("commit") + .once() + .mock() + ) + flexmock(PackitAPI).should_receive("dg").and_return(dg) + + flexmock(PackitAPI).should_receive("push_and_create_pr").once() + + flexmock(Signature).should_receive("apply_async").once() + flexmock(Pushgateway).should_receive("push").twice().and_return() + + processing_results = SteveJobs().process_message(onboarding_request_event) + event_dict, _, job_config, package_config = get_parameters_from_results( + processing_results[:1], + ) + assert json.dumps(event_dict) + results = run_onboarding_request_handler( + package_config=package_config, + event=event_dict, + job_config=job_config, + ) + + assert first_dict_value(results["job"])["success"] + + generated_config = (git_repo / ".packit.yaml").read_text() + + assert "pull_from_upstream" in generated_config + assert "koji_build" in generated_config + assert "bodhi_update" in generated_config