This is an automated email from the ASF dual-hosted git repository. sbp pushed a commit to branch sbp in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit a52d17f35a5c7ad8fa75592fb3625276ae4346d9 Author: Sean B. Palmer <[email protected]> AuthorDate: Fri Jan 30 15:20:36 2026 +0000 Associate check result ignores with projects not committees --- atr/api/__init__.py | 15 +-- atr/db/__init__.py | 6 +- atr/get/checks.py | 14 +-- atr/get/ignores.py | 16 +-- atr/models/api.py | 4 +- atr/models/sql.py | 2 +- atr/post/ignores.py | 29 ++--- atr/storage/readers/checks.py | 14 ++- atr/storage/readers/releases.py | 5 +- atr/storage/writers/checks.py | 22 +++- atr/templates/check-selected.html | 4 +- migrations/versions/0045_2026.01.30_9664bcb9.py | 135 ++++++++++++++++++++++++ 12 files changed, 203 insertions(+), 63 deletions(-) diff --git a/atr/api/__init__.py b/atr/api/__init__.py index 764b858..b5b28a0 100644 --- a/atr/api/__init__.py +++ b/atr/api/__init__.py @@ -396,8 +396,9 @@ async def ignore_add(data: models.api.IgnoreAddArgs) -> DictResponse: if not any(data.model_dump().values()): raise exceptions.BadRequest("At least one field must be provided") async with storage.write(asf_uid) as write: - wacm = write.as_committee_member(data.committee_name) + wacm = await write.as_project_committee_member(data.project_name) await wacm.checks.ignore_add( + data.project_name, data.release_glob, data.revision_number, data.checker_glob, @@ -425,7 +426,7 @@ async def ignore_delete(data: models.api.IgnoreDeleteArgs) -> DictResponse: if not any(data.model_dump().values()): raise exceptions.BadRequest("At least one field must be provided") async with storage.write(asf_uid) as write: - wacm = write.as_committee_member(data.committee) + wacm = await write.as_project_committee_member(data.project_name) # TODO: This is more like discard # Should potentially check for rowcount, and raise an error if it's 0 await wacm.checks.ignore_delete(data.id) @@ -436,15 +437,15 @@ async def ignore_delete(data: models.api.IgnoreDeleteArgs) -> DictResponse: # TODO: Rename to ignores [email protected]("/ignore/list/<committee_name>") [email protected]("/ignore/list/<project_name>") @quart_schema.validate_response(models.api.IgnoreListResults, 200) -async def ignore_list(committee_name: str) -> DictResponse: +async def ignore_list(project_name: str) -> DictResponse: """ - List ignores by committee name. + List ignores by project name. """ - _simple_check(committee_name) + _simple_check(project_name) async with db.session() as data: - ignores = await data.check_result_ignore(committee_name=committee_name).all() + ignores = await data.check_result_ignore(project_name=project_name).all() return models.api.IgnoreListResults( endpoint="/ignore/list", ignores=ignores, diff --git a/atr/db/__init__.py b/atr/db/__init__.py index 80ebbc5..eb454e0 100644 --- a/atr/db/__init__.py +++ b/atr/db/__init__.py @@ -196,7 +196,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession): def check_result_ignore( self, - committee_name: Opt[str] = NOT_SET, + project_name: Opt[str] = NOT_SET, release_glob: Opt[str] = NOT_SET, revision_number: Opt[str] = NOT_SET, checker_glob: Opt[str] = NOT_SET, @@ -207,8 +207,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession): ) -> Query[sql.CheckResultIgnore]: query = sqlmodel.select(sql.CheckResultIgnore) - if is_defined(committee_name): - query = query.where(sql.CheckResultIgnore.committee_name == committee_name) + if is_defined(project_name): + query = query.where(sql.CheckResultIgnore.project_name == project_name) if is_defined(release_glob): query = query.where(sql.CheckResultIgnore.release_glob == release_glob) if is_defined(revision_number): diff --git a/atr/get/checks.py b/atr/get/checks.py index df36f9e..0d76c73 100644 --- a/atr/get/checks.py +++ b/atr/get/checks.py @@ -85,15 +85,12 @@ class FileStats(NamedTuple): async def get_file_totals(release: sql.Release, session: web.Committer | None) -> FileStats: """Get file level check totals after ignores are applied.""" - if release.committee is None: - raise ValueError("Release has no committee") - base_path = util.release_directory(release) paths = [path async for path in util.paths_recursive(base_path)] async with storage.read(session) as read: ragp = read.as_general_public() - match_ignore = await ragp.checks.ignores_matcher(release.committee.name) + match_ignore = await ragp.checks.ignores_matcher(release.project_name) _, totals = await _compute_stats(release, paths, match_ignore) return totals @@ -119,7 +116,7 @@ async def selected(session: web.Committer | None, project_name: str, version_nam async with storage.read(session) as read: ragp = read.as_general_public() - match_ignore = await ragp.checks.ignores_matcher(release.committee.name) + match_ignore = await ragp.checks.ignores_matcher(release.project_name) per_file_stats, totals = await _compute_stats(release, paths, match_ignore) @@ -510,16 +507,13 @@ def _render_header(page: htm.Block, release: sql.Release) -> None: def _render_ignores_section(page: htm.Block, release: sql.Release) -> None: - if release.committee is None: - return - # TODO: We should choose a consistent " ..." or "... " style page.h2["Check ignores"] page.p[ - "Committee members can configure rules to ignore specific check results. " + "Project committee members can configure rules to ignore specific check results. " "Ignored checks are excluded from the counts shown above.", ] - ignores_url = util.as_url(ignores.ignores, committee_name=release.committee.name) + ignores_url = util.as_url(ignores.ignores, project_name=release.project.name) page.div[htpy.a(".btn.btn-outline-primary", href=ignores_url)["Manage check ignores"],] diff --git a/atr/get/ignores.py b/atr/get/ignores.py index a6575e0..5c741d3 100644 --- a/atr/get/ignores.py +++ b/atr/get/ignores.py @@ -27,24 +27,24 @@ import atr.util as util import atr.web as web [email protected]("/ignores/<committee_name>") -async def ignores(session: web.Committer, committee_name: str) -> str | web.WerkzeugResponse: [email protected]("/ignores/<project_name>") +async def ignores(session: web.Committer, project_name: str) -> str | web.WerkzeugResponse: async with storage.read() as read: ragp = read.as_general_public() - ignores = await ragp.checks.ignores(committee_name) + ignores = await ragp.checks.ignores(project_name) content = htm.div[ htm.h1["Ignored checks"], - htm.p[f"Manage ignored checks for committee {committee_name}."], - _add_ignore(committee_name), + htm.p[f"Manage ignored checks for project {project_name}."], + _add_ignore(project_name), _existing_ignores(ignores), ] return await template.blank("Ignored checks", content, javascripts=["ignore-form-change"]) -def _add_ignore(committee_name: str) -> htm.Element: - form_path = util.as_url(post.ignores.ignores, committee_name=committee_name) +def _add_ignore(project_name: str) -> htm.Element: + form_path = util.as_url(post.ignores.ignores, project_name=project_name) block = htm.Block(htm.div) block.h2["Add ignore"] block.p["Add a new ignore for a check result."] @@ -65,7 +65,7 @@ def _check_result_ignore_card(cri: sql.CheckResultIgnore) -> htm.Element: # Update form update_form_block = htm.Block(htm.div) - form_path_update = util.as_url(post.ignores.ignores, committee_name=cri.committee_name) + form_path_update = util.as_url(post.ignores.ignores, project_name=cri.project_name) status = shared.ignores.sql_to_ignore_status(cri.status) form.render_block( update_form_block, diff --git a/atr/models/api.py b/atr/models/api.py index 436f002..8a242f6 100644 --- a/atr/models/api.py +++ b/atr/models/api.py @@ -164,7 +164,7 @@ class DistributionRecordResults(schema.Strict): class IgnoreAddArgs(schema.Strict): - committee_name: str = schema.example("example") + project_name: str = schema.example("example") release_glob: str | None = schema.default_example(None, "example-0.0.*") revision_number: str | None = schema.default_example(None, "00001") checker_glob: str | None = schema.default_example(None, "atr.tasks.checks.license.files") @@ -194,7 +194,7 @@ class IgnoreAddResults(schema.Strict): class IgnoreDeleteArgs(schema.Strict): - committee: str = schema.example("example") + project_name: str = schema.example("example") id: int = schema.example(1) diff --git a/atr/models/sql.py b/atr/models/sql.py index e178e2f..cdbc51f 100644 --- a/atr/models/sql.py +++ b/atr/models/sql.py @@ -946,7 +946,7 @@ class CheckResultIgnore(sqlmodel.SQLModel, table=True): sa_column=sqlalchemy.Column(UTCDateTime), **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)), ) - committee_name: str = sqlmodel.Field(**example("example")) + project_name: str = sqlmodel.Field(foreign_key="project.name", **example("example")) release_glob: str | None = sqlmodel.Field(**example("example-0.0.*")) revision_number: str | None = sqlmodel.Field(**example("00001")) checker_glob: str | None = sqlmodel.Field(**example("atr.tasks.checks.license.files")) diff --git a/atr/post/ignores.py b/atr/post/ignores.py index 74e8815..974dbb2 100644 --- a/atr/post/ignores.py +++ b/atr/post/ignores.py @@ -23,32 +23,33 @@ import atr.storage as storage import atr.web as web [email protected]("/ignores/<committee_name>") [email protected]("/ignores/<project_name>") @post.form(shared.ignores.IgnoreForm) async def ignores( - session: web.Committer, ignore_form: shared.ignores.IgnoreForm, committee_name: str + session: web.Committer, ignore_form: shared.ignores.IgnoreForm, project_name: str ) -> web.WerkzeugResponse: """Handle forms on the ignores page.""" match ignore_form: case shared.ignores.AddIgnoreForm() as add_form: - return await _add_ignore(session, add_form, committee_name) + return await _add_ignore(session, add_form, project_name) case shared.ignores.DeleteIgnoreForm() as delete_form: - return await _delete_ignore(session, delete_form, committee_name) + return await _delete_ignore(session, delete_form, project_name) case shared.ignores.UpdateIgnoreForm() as update_form: - return await _update_ignore(session, update_form, committee_name) + return await _update_ignore(session, update_form, project_name) async def _add_ignore( - session: web.Committer, add_form: shared.ignores.AddIgnoreForm, committee_name: str + session: web.Committer, add_form: shared.ignores.AddIgnoreForm, project_name: str ) -> web.WerkzeugResponse: """Add a new ignore.""" status = shared.ignores.ignore_status_to_sql(add_form.status) # pyright: ignore[reportArgumentType] async with storage.write() as write: - wacm = write.as_committee_member(committee_name) + wacm = await write.as_project_committee_member(project_name) await wacm.checks.ignore_add( + project_name=project_name, release_glob=add_form.release_glob or None, revision_number=add_form.revision_number or None, checker_glob=add_form.checker_glob or None, @@ -60,34 +61,34 @@ async def _add_ignore( return await session.redirect( get.ignores.ignores, - committee_name=committee_name, + project_name=project_name, success="Ignore added", ) async def _delete_ignore( - session: web.Committer, delete_form: shared.ignores.DeleteIgnoreForm, committee_name: str + session: web.Committer, delete_form: shared.ignores.DeleteIgnoreForm, project_name: str ) -> web.WerkzeugResponse: """Delete an ignore.""" async with storage.write() as write: - wacm = write.as_committee_member(committee_name) + wacm = await write.as_project_committee_member(project_name) await wacm.checks.ignore_delete(id=delete_form.id) return await session.redirect( get.ignores.ignores, - committee_name=committee_name, + project_name=project_name, success="Ignore deleted", ) async def _update_ignore( - session: web.Committer, update_form: shared.ignores.UpdateIgnoreForm, committee_name: str + session: web.Committer, update_form: shared.ignores.UpdateIgnoreForm, project_name: str ) -> web.WerkzeugResponse: """Update an ignore.""" status = shared.ignores.ignore_status_to_sql(update_form.status) # pyright: ignore[reportArgumentType] async with storage.write() as write: - wacm = write.as_committee_member(committee_name) + wacm = await write.as_project_committee_member(project_name) await wacm.checks.ignore_update( id=update_form.id, release_glob=update_form.release_glob or None, @@ -101,6 +102,6 @@ async def _update_ignore( return await session.redirect( get.ignores.ignores, - committee_name=committee_name, + project_name=project_name, success="Ignore updated", ) diff --git a/atr/storage/readers/checks.py b/atr/storage/readers/checks.py index 15131bb..5af10c6 100644 --- a/atr/storage/readers/checks.py +++ b/atr/storage/readers/checks.py @@ -45,8 +45,6 @@ class GeneralPublic: self.__asf_uid = read.authorisation.asf_uid async def by_release_path(self, release: sql.Release, rel_path: pathlib.Path) -> types.CheckResults: - if release.committee is None: - raise ValueError("Release has no committee - Invalid state") if release.latest_revision_number is None: raise ValueError("Release has no revision - Invalid state") @@ -63,7 +61,7 @@ class GeneralPublic: # Filter out any results that are ignored unignored_checks = [] ignored_checks = [] - match_ignore = await self.ignores_matcher(release.committee.name) + match_ignore = await self.ignores_matcher(release.project_name) for cr in all_check_results: if not match_ignore(cr): unignored_checks.append(cr) @@ -87,18 +85,18 @@ class GeneralPublic: member_results_list[member_rel_path].sort(key=lambda r: r.checker) return types.CheckResults(primary_results_list, member_results_list, ignored_checks) - async def ignores(self, committee_name: str) -> list[sql.CheckResultIgnore]: + async def ignores(self, project_name: str) -> list[sql.CheckResultIgnore]: results = await self.__data.check_result_ignore( - committee_name=committee_name, + project_name=project_name, ).all() return list(results) async def ignores_matcher( self, - committee_name: str, + project_name: str, ) -> Callable[[sql.CheckResult], bool]: ignores = await self.__data.check_result_ignore( - committee_name=committee_name, + project_name=project_name, ).all() def match(cr: sql.CheckResult) -> bool: @@ -111,7 +109,7 @@ class GeneralPublic: return match def __check_ignore_match(self, cr: sql.CheckResult, cri: sql.CheckResultIgnore) -> bool: - # Does not check that the committee name matches + # Does not check that the project name matches if cr.status == sql.CheckResultStatus.SUCCESS: # Successes are never ignored return False diff --git a/atr/storage/readers/releases.py b/atr/storage/readers/releases.py index 8a3bff9..669fbfb 100644 --- a/atr/storage/readers/releases.py +++ b/atr/storage/readers/releases.py @@ -115,10 +115,7 @@ class GeneralPublic: async def __successes_errors_warnings( self, release: sql.Release, latest_revision_number: str, info: types.PathInfo ) -> None: - if release.committee is None: - raise ValueError("Release has no committee - Invalid state") - - match_ignore = await self.__read_as.checks.ignores_matcher(release.committee.name) + match_ignore = await self.__read_as.checks.ignores_matcher(release.project_name) cs = types.ChecksSubset( release=release, diff --git a/atr/storage/writers/checks.py b/atr/storage/writers/checks.py index ca18f2c..452559a 100644 --- a/atr/storage/writers/checks.py +++ b/atr/storage/writers/checks.py @@ -99,6 +99,7 @@ class CommitteeMember(CommitteeParticipant): async def ignore_add( self, + project_name: str, release_glob: str | None = None, revision_number: str | None = None, checker_glob: str | None = None, @@ -107,6 +108,7 @@ class CommitteeMember(CommitteeParticipant): status: sql.CheckResultStatusIgnore | None = None, message_glob: str | None = None, ) -> None: + await self.__validate_project_in_committee(project_name) _validate_ignore_patterns( release_glob, checker_glob, @@ -117,7 +119,7 @@ class CommitteeMember(CommitteeParticipant): cri = sql.CheckResultIgnore( asf_uid=self.__asf_uid, created=datetime.datetime.now(datetime.UTC), - committee_name=self.__committee_name, + project_name=project_name, release_glob=release_glob, revision_number=revision_number, checker_glob=checker_glob, @@ -134,6 +136,10 @@ class CommitteeMember(CommitteeParticipant): ) async def ignore_delete(self, id: int) -> None: + cri = await self.__data.get(sql.CheckResultIgnore, id) + if cri is None: + raise storage.AccessError(f"Ignore {id} not found") + await self.__validate_project_in_committee(cri.project_name) via = sql.validate_instrumented_attribute await self.__data.execute(sqlmodel.delete(sql.CheckResultIgnore).where(via(sql.CheckResultIgnore.id) == id)) await self.__data.commit() @@ -153,6 +159,10 @@ class CommitteeMember(CommitteeParticipant): status: sql.CheckResultStatusIgnore | None = None, message_glob: str | None = None, ) -> None: + cri = await self.__data.get(sql.CheckResultIgnore, id) + if cri is None: + raise storage.AccessError(f"Ignore {id} not found") + await self.__validate_project_in_committee(cri.project_name) _validate_ignore_patterns( release_glob, checker_glob, @@ -160,9 +170,6 @@ class CommitteeMember(CommitteeParticipant): member_rel_path_glob, message_glob, ) - cri = await self.__data.get(sql.CheckResultIgnore, id) - if cri is None: - raise storage.AccessError(f"Ignore {id} not found") # The updating ASF UID is now responsible for the whole ignore cri.asf_uid = self.__asf_uid cri.release_glob = release_glob @@ -177,3 +184,10 @@ class CommitteeMember(CommitteeParticipant): asf_uid=self.__asf_uid, cri=cri.model_dump_json(exclude_none=True), ) + + async def __validate_project_in_committee(self, project_name: str) -> None: + project = await self.__data.project(project_name, _committee=True).get() + if project is None: + raise storage.AccessError(f"Project {project_name} not found") + if project.committee_name != self.__committee_name: + raise storage.AccessError(f"Project {project_name} is not in committee {self.__committee_name}") diff --git a/atr/templates/check-selected.html b/atr/templates/check-selected.html index 7a77cc4..0a92433 100644 --- a/atr/templates/check-selected.html +++ b/atr/templates/check-selected.html @@ -145,7 +145,7 @@ {% set ignored_errors_count = info.ignored_errors|length %} {% set ignored_warnings_count = info.ignored_warnings|length %} <p> - There were {{ ignored_errors_count }} {{ 'error' if ignored_errors_count == 1 else 'errors' }} and {{ ignored_warnings_count }} {{ 'warning' if ignored_warnings_count == 1 else 'warnings' }} ignored by policy set by committee members. This does not include archive member checks. + There were {{ ignored_errors_count }} {{ 'error' if ignored_errors_count == 1 else 'errors' }} and {{ ignored_warnings_count }} {{ 'warning' if ignored_warnings_count == 1 else 'warnings' }} ignored by policy set by project committee members. This does not include archive member checks. </p> <details> <summary>Show ignored checks</summary> @@ -191,7 +191,7 @@ <p>No ignored checks found.</p> {% endif %} <p> - You can also <a href="{{ as_url(get.ignores.ignores, committee_name=release.committee.name) }}">manage which check results are ignored</a>. + You can also <a href="{{ as_url(get.ignores.ignores, project_name=release.project_name) }}">manage which check results are ignored</a>. </p> <h3 id="debugging" class="mt-4">Debugging</h3> diff --git a/migrations/versions/0045_2026.01.30_9664bcb9.py b/migrations/versions/0045_2026.01.30_9664bcb9.py new file mode 100644 index 0000000..0d089d9 --- /dev/null +++ b/migrations/versions/0045_2026.01.30_9664bcb9.py @@ -0,0 +1,135 @@ +"""Associate check result ignores with projects not committees + +Revision ID: 0045_2026.01.30_9664bcb9 +Revises: 0044_2026.01.29_24d82084 +Create Date: 2026-01-30 14:44:47.168525+00:00 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# Revision identifiers, used by Alembic +revision: str = "0045_2026.01.30_9664bcb9" +down_revision: str | None = "0044_2026.01.29_24d82084" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Add the new project name column + with op.batch_alter_table("checkresultignore", schema=None) as batch_op: + batch_op.add_column(sa.Column("project_name", sa.String(), nullable=True)) + batch_op.create_foreign_key( + batch_op.f("fk_checkresultignore_project_name_project"), "project", ["project_name"], ["name"] + ) + + bind = op.get_bind() + check_result_ignore = sa.table( + "checkresultignore", + sa.column("id", sa.Integer), + sa.column("asf_uid", sa.String), + sa.column("created", sa.DateTime), + sa.column("committee_name", sa.String), + sa.column("project_name", sa.String), + sa.column("release_glob", sa.String), + sa.column("revision_number", sa.String), + sa.column("checker_glob", sa.String), + sa.column("primary_rel_path_glob", sa.String), + sa.column("member_rel_path_glob", sa.String), + sa.column("status", sa.String), + sa.column("message_glob", sa.String), + ) + project = sa.table( + "project", + sa.column("name", sa.String), + sa.column("committee_name", sa.String), + ) + + existing_ignores = bind.execute( + sa.select( + check_result_ignore.c.asf_uid, + check_result_ignore.c.created, + check_result_ignore.c.committee_name, + check_result_ignore.c.release_glob, + check_result_ignore.c.revision_number, + check_result_ignore.c.checker_glob, + check_result_ignore.c.primary_rel_path_glob, + check_result_ignore.c.member_rel_path_glob, + check_result_ignore.c.status, + check_result_ignore.c.message_glob, + ).where(check_result_ignore.c.project_name.is_(None)) + ).fetchall() + + # Expand each committee scoped ignore to cover all projects in its committee + for ignore in existing_ignores: + project_names = bind.execute( + sa.select(project.c.name).where(project.c.committee_name == ignore.committee_name) + ).fetchall() + for (project_name,) in project_names: + bind.execute( + check_result_ignore.insert().values( + asf_uid=ignore.asf_uid, + created=ignore.created, + committee_name=ignore.committee_name, + project_name=project_name, + release_glob=ignore.release_glob, + revision_number=ignore.revision_number, + checker_glob=ignore.checker_glob, + primary_rel_path_glob=ignore.primary_rel_path_glob, + member_rel_path_glob=ignore.member_rel_path_glob, + status=ignore.status, + message_glob=ignore.message_glob, + ) + ) + + # Remove the original committee scoped ignores + bind.execute(check_result_ignore.delete().where(check_result_ignore.c.project_name.is_(None))) + + # Complete the schema transition + with op.batch_alter_table("checkresultignore", schema=None) as batch_op: + batch_op.alter_column("project_name", nullable=False) + batch_op.drop_column("committee_name") + + +def downgrade() -> None: + # Restore the committee name column + with op.batch_alter_table("checkresultignore", schema=None) as batch_op: + batch_op.add_column(sa.Column("committee_name", sa.VARCHAR(), nullable=True)) + + bind = op.get_bind() + check_result_ignore = sa.table( + "checkresultignore", + sa.column("id", sa.Integer), + sa.column("committee_name", sa.String), + sa.column("project_name", sa.String), + ) + project = sa.table( + "project", + sa.column("name", sa.String), + sa.column("committee_name", sa.String), + ) + + # Populate committee names from project ownership or delete orphaned ignores + project_committee_map = { + name: committee_name + for name, committee_name in bind.execute(sa.select(project.c.name, project.c.committee_name)).fetchall() + } + ignore_rows = bind.execute(sa.select(check_result_ignore.c.id, check_result_ignore.c.project_name)).fetchall() + for ignore_id, project_name in ignore_rows: + committee_name = project_committee_map.get(project_name) + if committee_name is None: + bind.execute(check_result_ignore.delete().where(check_result_ignore.c.id == ignore_id)) + continue + bind.execute( + check_result_ignore.update() + .where(check_result_ignore.c.id == ignore_id) + .values(committee_name=committee_name) + ) + + # Complete the schema rollback + with op.batch_alter_table("checkresultignore", schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f("fk_checkresultignore_project_name_project"), type_="foreignkey") + batch_op.drop_column("project_name") + batch_op.alter_column("committee_name", nullable=False) --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
