sbp commented on code in PR #1376: URL: https://github.com/apache/tooling-trusted-releases/pull/1376#discussion_r3573578631
########## atr/shared/catalogue_diff.py: ########## @@ -0,0 +1,494 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import dataclasses +import enum + +import pydantic + +import atr.shared.catalogue_rows as catalogue_rows + + +class Mode(enum.StrEnum): + # REPLACE clears the committee and re-adds from the files, so every row is an add. ADDITIVE + # matches each row against what is already there, so a row can also move, re-home, or say + # nothing new. The two share their types and their parsing, but not a code path + REPLACE = "replace" + ADDITIVE = "additive" + + [email protected](frozen=True) +class DbSnapshot: + project_committee: dict[str, str] + release_project: dict[str, str] + artifact_by_dist: dict[tuple[str, str], tuple[str, str, str]] + managed_project_keys: frozenset[str] + # Every release key foundation-wide, unlike the workspace-scoped release_project + release_keys: frozenset[str] = dataclasses.field(default_factory=frozenset) + # The workspace committee's artifact primary keys, (project_key, version, artifact_path) + artifact_pks: frozenset[tuple[str, str, str]] = dataclasses.field(default_factory=frozenset) + + [email protected](frozen=True) +class Conflict: + table: str + key: str + reason: str + + [email protected](frozen=True) +class ArtifactRehome: + dist: tuple[str, str] + from_pk: tuple[str, str, str] + to_row: catalogue_rows.ArtifactRow + + [email protected](frozen=True) +class ReleaseMove: + key: str + from_project: str + to_project: str + row: catalogue_rows.ReleaseRow + + [email protected] +class CatalogueDiff: + adds: list[catalogue_rows.Row] = dataclasses.field(default_factory=list) + artifact_rehomes: list[ArtifactRehome] = dataclasses.field(default_factory=list) + release_moves: list[ReleaseMove] = dataclasses.field(default_factory=list) + conflicts: list[Conflict] = dataclasses.field(default_factory=list) + project_deletions: list[str] = dataclasses.field(default_factory=list) + release_deletions: list[str] = dataclasses.field(default_factory=list) + artifact_deletions: list[tuple[str, str, str]] = dataclasses.field(default_factory=list) + warnings: list[str] = dataclasses.field(default_factory=list) + unchanged: int = 0 + # A replace applies as a whole or not at all, so a conflict leaves the committee untouched + refused: bool = False + + @property + def counts(self) -> dict[str, int]: + return { + "add": len(self.adds), + "artifact_rehome": len(self.artifact_rehomes), + "release_move": len(self.release_moves), + "delete": len(self.project_deletions) + len(self.release_deletions) + len(self.artifact_deletions), + "conflict": len(self.conflicts), + "unchanged": self.unchanged, + } + + +def classify( + rows_by_table: dict[str, list[dict[str, str]]], snapshot: DbSnapshot, committee_key: str, mode: Mode +) -> CatalogueDiff: + match mode: + case Mode.REPLACE: + return classify_replace(rows_by_table, snapshot, committee_key) + case Mode.ADDITIVE: + return classify_additive(rows_by_table, snapshot, committee_key) + + +def classify_additive( + rows_by_table: dict[str, list[dict[str, str]]], snapshot: DbSnapshot, committee_key: str +) -> CatalogueDiff: + # Rows are matched against what the committee already holds. A row that matches nothing is an + # add, one that matches somewhere else is a move or a re-home, and one that matches where it + # says it is is unchanged. Nothing is deleted, so a bad row is skipped and the rest still apply + diff = CatalogueDiff() + projects, releases, artifacts = _parse_all(rows_by_table, diff) + _additive_projects(projects, snapshot, diff, committee_key) + _additive_releases(releases, snapshot, diff, committee_key) + _additive_artifacts(artifacts, snapshot, diff, committee_key) + return diff + + +def classify_replace( + rows_by_table: dict[str, list[dict[str, str]]], snapshot: DbSnapshot, committee_key: str +) -> CatalogueDiff: + # The files *are* the committee's catalogue. The deepest table uploaded decides what is deleted, + # which cascades downwards, and every uploaded row becomes a plain add + diff = CatalogueDiff() + snapshot = _clear(rows_by_table, snapshot, diff, committee_key) + projects, releases, artifacts = _parse_all(rows_by_table, diff) + _replace_projects(projects, snapshot, diff, committee_key) + _replace_releases(releases, snapshot, diff, committee_key) + _replace_artifacts(artifacts, snapshot, diff, committee_key) + if diff.conflicts: + # A row that cannot be applied would be deleted and not restored, so the files only describe + # a complete catalogue if every row in them is good + return _refused(diff) + return diff + + +def _additive_artifacts( + rows: list[catalogue_rows.ArtifactRow], snapshot: DbSnapshot, diff: CatalogueDiff, committee_key: str +) -> None: + # An artifact stays within its own committee; a cross-committee correction moves the whole + # release, which carries its artifacts + known_projects = _known_projects(snapshot, diff, committee_key) + known_releases = ( + set(snapshot.release_project) + | {str(add.key) for add in diff.adds if isinstance(add, catalogue_rows.ReleaseRow)} + | {f"{move.to_project}-{move.row.version}" for move in diff.release_moves} + ) + seen_dists: set[tuple[str, str]] = set() + seen_pks: set[tuple[str, str, str]] = set() Review Comment: You can move a release in `releases`, and that moves its artifacts along with it. If you then do an add in `artifacts` (the separate field for that), using the same name as an artifact's destination in the previous step, the code creates two duplicate rows for the artifact. This then fails because of the unique constraint in the database. Here's a test case for this one: ``` def test_classify_collision() -> None: snap = _snapshot( project_committee={"alpha-one": "alpha", "alpha-two": "alpha"}, artifact_pks=frozenset({("alpha-one", "1.0.0", "a.tgz")}), ) rows = { "releases": [_release("alpha-one-1.0.0", "alpha-two", "1.0.0")], "artifacts": [_artifact("alpha-two", "1.0.0", "a.tgz", "alpha/new")], } d = catalogue_diff.classify_additive(rows, snap, "alpha") assert d.counts["conflict"] == 1 ``` (This fails, but it shouldn't.) ########## atr/admin/__init__.py: ########## @@ -680,6 +685,228 @@ async def catalog_remove_post( return await session.redirect(catalog_committee_get, committee_key=str(committee_key)) +_CATALOG_PREVIEW_LIMIT: Final[int] = 50 + + +class CatalogImportForm(form.Form): + projects: form.File = form.label("Projects CSV", "Optional.") + releases: form.File = form.label("Releases CSV", "Optional.") + artifacts: form.File = form.label("Artifacts CSV", "Optional.") + additive: bool = form.label( Review Comment: This field is `default=True`, but because browsers don't send anything when the field is unchecked, that's when we apply the default. So when the field is checked, the browser sends `"on"` and we set `True`. If the field is unchecked, the browser sends nothing, and we use the default, which is `True`. Therefore it's impossible to turn this field off. I wonder if we can add a static lint for this. Might be difficult because the field element is a consequence of the Python type of the property. ########## atr/shared/catalogue_diff.py: ########## @@ -0,0 +1,494 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import dataclasses +import enum + +import pydantic + +import atr.shared.catalogue_rows as catalogue_rows + + +class Mode(enum.StrEnum): + # REPLACE clears the committee and re-adds from the files, so every row is an add. ADDITIVE + # matches each row against what is already there, so a row can also move, re-home, or say + # nothing new. The two share their types and their parsing, but not a code path + REPLACE = "replace" + ADDITIVE = "additive" + + [email protected](frozen=True) +class DbSnapshot: + project_committee: dict[str, str] + release_project: dict[str, str] + artifact_by_dist: dict[tuple[str, str], tuple[str, str, str]] + managed_project_keys: frozenset[str] + # Every release key foundation-wide, unlike the workspace-scoped release_project + release_keys: frozenset[str] = dataclasses.field(default_factory=frozenset) + # The workspace committee's artifact primary keys, (project_key, version, artifact_path) + artifact_pks: frozenset[tuple[str, str, str]] = dataclasses.field(default_factory=frozenset) + + [email protected](frozen=True) +class Conflict: + table: str + key: str + reason: str + + [email protected](frozen=True) +class ArtifactRehome: + dist: tuple[str, str] + from_pk: tuple[str, str, str] + to_row: catalogue_rows.ArtifactRow + + [email protected](frozen=True) +class ReleaseMove: + key: str + from_project: str + to_project: str + row: catalogue_rows.ReleaseRow + + [email protected] +class CatalogueDiff: + adds: list[catalogue_rows.Row] = dataclasses.field(default_factory=list) + artifact_rehomes: list[ArtifactRehome] = dataclasses.field(default_factory=list) + release_moves: list[ReleaseMove] = dataclasses.field(default_factory=list) + conflicts: list[Conflict] = dataclasses.field(default_factory=list) + project_deletions: list[str] = dataclasses.field(default_factory=list) + release_deletions: list[str] = dataclasses.field(default_factory=list) + artifact_deletions: list[tuple[str, str, str]] = dataclasses.field(default_factory=list) + warnings: list[str] = dataclasses.field(default_factory=list) + unchanged: int = 0 + # A replace applies as a whole or not at all, so a conflict leaves the committee untouched + refused: bool = False + + @property + def counts(self) -> dict[str, int]: + return { + "add": len(self.adds), + "artifact_rehome": len(self.artifact_rehomes), + "release_move": len(self.release_moves), + "delete": len(self.project_deletions) + len(self.release_deletions) + len(self.artifact_deletions), + "conflict": len(self.conflicts), + "unchanged": self.unchanged, + } + + +def classify( + rows_by_table: dict[str, list[dict[str, str]]], snapshot: DbSnapshot, committee_key: str, mode: Mode +) -> CatalogueDiff: + match mode: + case Mode.REPLACE: + return classify_replace(rows_by_table, snapshot, committee_key) + case Mode.ADDITIVE: + return classify_additive(rows_by_table, snapshot, committee_key) + + +def classify_additive( + rows_by_table: dict[str, list[dict[str, str]]], snapshot: DbSnapshot, committee_key: str +) -> CatalogueDiff: + # Rows are matched against what the committee already holds. A row that matches nothing is an + # add, one that matches somewhere else is a move or a re-home, and one that matches where it + # says it is is unchanged. Nothing is deleted, so a bad row is skipped and the rest still apply + diff = CatalogueDiff() + projects, releases, artifacts = _parse_all(rows_by_table, diff) + _additive_projects(projects, snapshot, diff, committee_key) + _additive_releases(releases, snapshot, diff, committee_key) + _additive_artifacts(artifacts, snapshot, diff, committee_key) + return diff + + +def classify_replace( + rows_by_table: dict[str, list[dict[str, str]]], snapshot: DbSnapshot, committee_key: str +) -> CatalogueDiff: + # The files *are* the committee's catalogue. The deepest table uploaded decides what is deleted, + # which cascades downwards, and every uploaded row becomes a plain add + diff = CatalogueDiff() + snapshot = _clear(rows_by_table, snapshot, diff, committee_key) + projects, releases, artifacts = _parse_all(rows_by_table, diff) + _replace_projects(projects, snapshot, diff, committee_key) + _replace_releases(releases, snapshot, diff, committee_key) + _replace_artifacts(artifacts, snapshot, diff, committee_key) + if diff.conflicts: + # A row that cannot be applied would be deleted and not restored, so the files only describe + # a complete catalogue if every row in them is good + return _refused(diff) + return diff + + +def _additive_artifacts( + rows: list[catalogue_rows.ArtifactRow], snapshot: DbSnapshot, diff: CatalogueDiff, committee_key: str +) -> None: + # An artifact stays within its own committee; a cross-committee correction moves the whole + # release, which carries its artifacts + known_projects = _known_projects(snapshot, diff, committee_key) + known_releases = ( + set(snapshot.release_project) + | {str(add.key) for add in diff.adds if isinstance(add, catalogue_rows.ReleaseRow)} + | {f"{move.to_project}-{move.row.version}" for move in diff.release_moves} + ) + seen_dists: set[tuple[str, str]] = set() + seen_pks: set[tuple[str, str, str]] = set() + for row in rows: + path = str(row.artifact_path) + pk = (str(row.project_key), str(row.version), path) + reference_conflict = _artifact_reference_conflict(row, snapshot, known_projects, known_releases) + if reference_conflict is not None: + diff.conflicts.append(reference_conflict) + continue + suffix = str(row.download_path_suffix) if row.download_path_suffix else "" + if ((suffix, path) in seen_dists) or (pk in seen_pks): + diff.conflicts.append(Conflict(table="artifacts", key=path, reason="listed more than once in this file")) + continue + seen_dists.add((suffix, path)) + seen_pks.add(pk) + matched = snapshot.artifact_by_dist.get((suffix, path)) if suffix else None + if (matched is not None) and (matched == pk): + diff.unchanged += 1 + continue + if matched is not None: + if matched[0] in snapshot.managed_project_keys: + diff.conflicts.append( + Conflict(table="artifacts", key=path, reason="source project has live workflow data") + ) + continue + if pk in snapshot.artifact_pks: + diff.conflicts.append(Conflict(table="artifacts", key=path, reason="re-home target already exists")) + continue + diff.artifact_rehomes.append(ArtifactRehome(dist=(suffix, path), from_pk=matched, to_row=row)) + continue + if not suffix: + diff.conflicts.append( + Conflict(table="artifacts", key=path, reason="unresolvable identity (no dist suffix)") + ) + continue + # The dist path is the artifact's identity, so an edited one describes a new artifact + if pk in snapshot.artifact_pks: + diff.conflicts.append( + Conflict(table="artifacts", key=path, reason="artifact already exists at that project and version") + ) + continue + diff.adds.append(row) + + +def _additive_projects( + rows: list[catalogue_rows.ProjectRow], snapshot: DbSnapshot, diff: CatalogueDiff, committee_key: str +) -> None: + seen: set[str] = set() + for row in rows: + key = str(row.key) + if key in seen: + diff.conflicts.append(Conflict(table="projects", key=key, reason="listed more than once in this file")) + continue + seen.add(key) + conflict = _project_reference_conflict(row, snapshot, committee_key) + if conflict is not None: + diff.conflicts.append(conflict) + continue + if key in snapshot.project_committee: + diff.unchanged += 1 + continue + diff.adds.append(row) + + +def _additive_releases( + rows: list[catalogue_rows.ReleaseRow], snapshot: DbSnapshot, diff: CatalogueDiff, committee_key: str +) -> None: + known_projects = _known_projects(snapshot, diff, committee_key) + seen: set[str] = set() + # Keys this diff will end up holding, so two rows cannot converge on one release + claimed: set[str] = set() + for row in rows: + key = str(row.key) + if key in seen: + diff.conflicts.append(Conflict(table="releases", key=key, reason="listed more than once in this file")) + continue + seen.add(key) + target_project = str(row.project_key) + current_project = snapshot.release_project.get(key) + reference_conflict = _release_reference_conflict(row, snapshot, known_projects, current_project) + if reference_conflict is not None: + diff.conflicts.append(reference_conflict) + continue + if current_project is None: + if (key in snapshot.release_keys) or (key in claimed): + diff.conflicts.append(Conflict(table="releases", key=key, reason=f"release '{key}' already exists")) + continue + claimed.add(key) + diff.adds.append(row) + continue + if current_project == target_project: + diff.unchanged += 1 + continue + version = str(row.version) + target_release_key = f"{target_project}-{version}" + if (target_release_key in snapshot.release_keys) or (target_release_key in claimed): + diff.conflicts.append( + Conflict(table="releases", key=key, reason=f"target already has release '{target_release_key}'") + ) + continue + collision = _move_artifact_collision(snapshot, current_project, target_project, version) + if collision is not None: + diff.conflicts.append(Conflict(table="releases", key=key, reason=collision)) + continue + claimed.add(target_release_key) + diff.release_moves.append( + ReleaseMove(key=key, from_project=current_project, to_project=target_project, row=row) + ) + + +def _artifact_reference_conflict( + row: catalogue_rows.ArtifactRow, snapshot: DbSnapshot, known_projects: set[str], known_releases: set[str] +) -> Conflict | None: + path = str(row.artifact_path) + project_key = str(row.project_key) + if project_key in snapshot.managed_project_keys: + return Conflict(table="artifacts", key=path, reason="target project has live workflow data") + if project_key not in known_projects: + return Conflict(table="artifacts", key=path, reason=f"project '{project_key}' is not in this committee") + if row.release_key not in known_releases: + return Conflict(table="artifacts", key=path, reason=f"release '{row.release_key}' does not exist") + return None + + +def _clear( + rows_by_table: dict[str, list[dict[str, str]]], snapshot: DbSnapshot, diff: CatalogueDiff, committee_key: str +) -> DbSnapshot: + # Projects with live workflow data stay in the snapshot, and are refused as usual + catalogued = { + key + for key, committee in snapshot.project_committee.items() + if (committee == committee_key) and (key not in snapshot.managed_project_keys) + } + clear_projects = "projects" in rows_by_table + clear_releases = clear_projects or ("releases" in rows_by_table) + clear_artifacts = clear_releases or ("artifacts" in rows_by_table) + + releases = {key for key, project in snapshot.release_project.items() if project in catalogued} + artifacts = {dist for dist, pk in snapshot.artifact_by_dist.items() if pk[0] in catalogued} + # Keyed on the primary keys, so an artifact with no dist path is cleared too + artifact_pks = {pk for pk in snapshot.artifact_pks if pk[0] in catalogued} + + if clear_projects: + diff.project_deletions.extend(sorted(catalogued)) + if clear_releases: + diff.release_deletions.extend(sorted(releases)) + if clear_artifacts: + diff.artifact_deletions.extend(sorted(artifact_pks)) + _warn_about_empty_levels(rows_by_table, diff, len(releases), len(artifact_pks)) + + return dataclasses.replace( + snapshot, + project_committee={ + key: committee + for key, committee in snapshot.project_committee.items() + if not (clear_projects and (key in catalogued)) + }, + release_project={ + key: project + for key, project in snapshot.release_project.items() + if not (clear_releases and (key in releases)) + }, + release_keys=snapshot.release_keys - (releases if clear_releases else set()), + artifact_by_dist={ + dist: pk for dist, pk in snapshot.artifact_by_dist.items() if not (clear_artifacts and (dist in artifacts)) + }, + artifact_pks=snapshot.artifact_pks - (artifact_pks if clear_artifacts else set()), + ) + + +def _first_error(error: pydantic.ValidationError) -> str: + detail = error.errors()[0] + field = ".".join(str(part) for part in detail["loc"]) + message = detail["msg"].removeprefix("Value error, ") + return f"invalid {field}: {message}" + + +def _known_projects(snapshot: DbSnapshot, diff: CatalogueDiff, committee_key: str) -> set[str]: + # A project added earlier in this diff counts as present + return {key for key, committee in snapshot.project_committee.items() if committee == committee_key} | { + str(add.key) for add in diff.adds if isinstance(add, catalogue_rows.ProjectRow) + } + + +def _move_artifact_collision(snapshot: DbSnapshot, from_project: str, to_project: str, version: str) -> str | None: + # A move rewrites its artifacts' project key, which is part of their primary key + for _, _, path in (pk for pk in snapshot.artifact_pks if (pk[0] == from_project) and (pk[1] == version)): + if (to_project, version, path) in snapshot.artifact_pks: + return f"target already has artifact '{path}' at version {version}" + return None + + +def _parse[R: pydantic.BaseModel]( + diff: CatalogueDiff, table: str, identity: str, model_cls: type[R], rows: list[dict[str, str]] +) -> list[R]: + parsed: list[R] = [] + for raw in rows: + try: + parsed.append(model_cls.model_validate(raw)) + except pydantic.ValidationError as error: + diff.conflicts.append(Conflict(table=table, key=raw.get(identity, "?"), reason=_first_error(error))) + return parsed + + +def _parse_all( + rows_by_table: dict[str, list[dict[str, str]]], diff: CatalogueDiff +) -> tuple[list[catalogue_rows.ProjectRow], list[catalogue_rows.ReleaseRow], list[catalogue_rows.ArtifactRow]]: + return ( + _parse(diff, "projects", "key", catalogue_rows.ProjectRow, rows_by_table.get("projects", [])), + _parse(diff, "releases", "key", catalogue_rows.ReleaseRow, rows_by_table.get("releases", [])), + _parse(diff, "artifacts", "artifact_path", catalogue_rows.ArtifactRow, rows_by_table.get("artifacts", [])), + ) + + +def _project_reference_conflict( + row: catalogue_rows.ProjectRow, snapshot: DbSnapshot, committee_key: str +) -> Conflict | None: + key = str(row.key) + if key in snapshot.managed_project_keys: + return Conflict(table="projects", key=key, reason="project has live workflow data") + committee = str(row.committee_key) if row.committee_key else "" + if committee != committee_key: + return Conflict(table="projects", key=key, reason=f"committee '{committee}' is not this committee") + # A project key is unique foundation-wide, so one already held elsewhere cannot be taken + holder = snapshot.project_committee.get(key) + if (holder is not None) and (holder != committee_key): + return Conflict(table="projects", key=key, reason=f"project '{key}' belongs to committee '{holder}'") + return None + + +def _refused(diff: CatalogueDiff) -> CatalogueDiff: + return CatalogueDiff( + conflicts=diff.conflicts, + refused=True, + warnings=[ + "Nothing was changed. Your files replace this committee's catalogue, so a row that cannot" + " be applied would be deleted and not restored. Fix the conflicts below and upload again." + ], + ) + + +def _release_reference_conflict( + row: catalogue_rows.ReleaseRow, + snapshot: DbSnapshot, + known_projects: set[str], + current_project: str | None, +) -> Conflict | None: + key = str(row.key) + target_project = str(row.project_key) + # A release key is "{owning project}-{version}", so the three columns have one degree of freedom + # between them; an edit that breaks the derivation is refused rather than applied + owner = current_project if (current_project is not None) else target_project + if key != f"{owner}-{row.version}": + return Conflict(table="releases", key=key, reason=f"key does not match version '{row.version}'") + if target_project in snapshot.managed_project_keys: + return Conflict(table="releases", key=key, reason="target project has live workflow data") + if target_project not in known_projects: + if target_project in snapshot.project_committee: + return Conflict(table="releases", key=key, reason=f"project '{target_project}' is not in this committee") + return Conflict(table="releases", key=key, reason=f"target project '{target_project}' does not exist") + if (current_project is not None) and (current_project in snapshot.managed_project_keys): + return Conflict(table="releases", key=key, reason="source project has live workflow data") + return None + + +def _replace_artifacts( + rows: list[catalogue_rows.ArtifactRow], snapshot: DbSnapshot, diff: CatalogueDiff, committee_key: str +) -> None: + known_projects = _known_projects(snapshot, diff, committee_key) + known_releases = set(snapshot.release_project) | { + str(add.key) for add in diff.adds if isinstance(add, catalogue_rows.ReleaseRow) + } + seen_dists: set[tuple[str, str]] = set() + seen_pks: set[tuple[str, str, str]] = set() + for row in rows: + path = str(row.artifact_path) + pk = (str(row.project_key), str(row.version), path) + # Two rows sharing a dist path would be two catalogue entries for one file in svn + dist = (str(row.download_path_suffix), path) if row.download_path_suffix else None + if (pk in seen_pks) or ((dist is not None) and (dist in seen_dists)): Review Comment: So `seen_dists` is what was seen in the file, not in the db? But ATR managed projects are skipped, so if the uploaded CSV contained a file in an unmanaged project that was actually in a managed project, would you not end up with a duplicate? We check that it's not duplicated within the CSV, that the target project isn't managed in ATR, that the committee and release exist, and that the primary key is free, but all of those would pass in this circumstance and yet there'd still be a duplicate. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
