You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/05/25 19:28:40 UTC
(tooling-trusted-releases) branch sbp updated: Add code to support an SVN publication task
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
The following commit(s) were added to refs/heads/sbp by this push:
new 43de5880 Add code to support an SVN publication task
43de5880 is described below
commit 43de588069c1c491da3b597c0619949678f723e7
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Mon May 25 20:26:01 2026 +0100
Add code to support an SVN publication task
---
atr/db/interaction.py | 89 +++++++++++++++
atr/models/args.py | 14 +++
atr/models/results.py | 10 ++
atr/models/sql.py | 3 +
atr/storage/writers/release.py | 12 +-
atr/tasks/__init__.py | 2 +
atr/util.py | 31 +++++
tests/unit/test_svn_publish_helpers.py | 80 +++++++++++++
tests/unit/test_svn_publish_tasks_db.py | 194 ++++++++++++++++++++++++++++++++
9 files changed, 429 insertions(+), 6 deletions(-)
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index cb4f9541..48ec823b 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -23,6 +23,7 @@ from typing import Final
import asfquart.base as base
import packaging.version as version
+import pydantic
import sqlalchemy
import sqlalchemy.orm as orm
import sqlmodel
@@ -542,6 +543,30 @@ async def prior_release_for_archive(project: sql.Project, version: str) -> sql.R
return cycles.prior_release_in_cycle(project, version, candidates)
+async def release_completed_svn_publish_task_for_revision(
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ revision_number: safe.RevisionNumber,
+ caller_data: db.Session | None = None,
+) -> sql.Task | None:
+ """Return the most recently completed usable SVN_PUBLISH task for the revision."""
+ via = sql.validate_instrumented_attribute
+ async with db.ensure_session(caller_data) as data:
+ query = (
+ sqlmodel.select(sql.Task)
+ .where(sql.Task.project_key == str(project_key))
+ .where(sql.Task.version_key == str(version_key))
+ .where(sql.Task.revision_number == str(revision_number))
+ .where(sql.Task.task_type == sql.TaskType.SVN_PUBLISH)
+ .where(sql.Task.status == sql.TaskStatus.COMPLETED)
+ .order_by(via(sql.Task.added).desc())
+ )
+ for task in (await data.execute(query)).scalars().all():
+ if _svn_publish_result(task) is not None:
+ return task
+ return None
+
+
async def release_current_vote_task(release: sql.Release, caller_data: db.Session | None = None) -> sql.Task | None:
current_vote_seq = getattr(release, "current_vote_seq", None)
if current_vote_seq is None:
@@ -561,6 +586,56 @@ async def release_current_vote_task(release: sql.Release, caller_data: db.Sessio
return task
+async def release_in_flight_svn_publish_task(
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ revision_number: safe.RevisionNumber,
+ target_url: str | None = None,
+ caller_data: db.Session | None = None,
+) -> sql.Task | None:
+ """Return the most recent queued or active SVN_PUBLISH task."""
+ via = sql.validate_instrumented_attribute
+ async with db.ensure_session(caller_data) as data:
+ query = (
+ sqlmodel.select(sql.Task)
+ .where(sql.Task.project_key == str(project_key))
+ .where(sql.Task.version_key == str(version_key))
+ .where(sql.Task.revision_number == str(revision_number))
+ .where(sql.Task.task_type == sql.TaskType.SVN_PUBLISH)
+ .where(via(sql.Task.status).in_([sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE]))
+ .order_by(via(sql.Task.added).desc())
+ .limit(1)
+ )
+ if target_url is not None:
+ query = query.where(sqlalchemy.func.json_extract(sql.Task.task_args, "$.target_url") == target_url)
+ return (await data.execute(query)).scalar_one_or_none()
+
+
+async def release_latest_failed_svn_publish_task(
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ revision_number: safe.RevisionNumber,
+ target_url: str | None = None,
+ caller_data: db.Session | None = None,
+) -> sql.Task | None:
+ """Return the most recently failed SVN_PUBLISH task."""
+ via = sql.validate_instrumented_attribute
+ async with db.ensure_session(caller_data) as data:
+ query = (
+ sqlmodel.select(sql.Task)
+ .where(sql.Task.project_key == str(project_key))
+ .where(sql.Task.version_key == str(version_key))
+ .where(sql.Task.revision_number == str(revision_number))
+ .where(sql.Task.task_type == sql.TaskType.SVN_PUBLISH)
+ .where(sql.Task.status == sql.TaskStatus.FAILED)
+ .order_by(via(sql.Task.added).desc())
+ .limit(1)
+ )
+ if target_url is not None:
+ query = query.where(sqlalchemy.func.json_extract(sql.Task.task_args, "$.target_url") == target_url)
+ return (await data.execute(query)).scalar_one_or_none()
+
+
async def release_latest_vote_task(release: sql.Release, caller_data: db.Session | None = None) -> sql.Task | None:
"""Find the most recent VOTE_INITIATE task for this release."""
via = sql.validate_instrumented_attribute
@@ -956,6 +1031,20 @@ async def wait_for_task(
return False
+def _svn_publish_result(task: sql.Task) -> results.SvnPublish | None:
+ result = task.result
+ if isinstance(result, results.SvnPublish):
+ return result
+ if isinstance(result, dict):
+ try:
+ parsed = results.ResultsAdapter.validate_python(result)
+ except pydantic.ValidationError:
+ return None
+ if isinstance(parsed, results.SvnPublish):
+ return parsed
+ return None
+
+
async def _trusted_ballot_details_from_ballots(
release: sql.Release,
ballots: Sequence[sql.BallotPaper],
diff --git a/atr/models/args.py b/atr/models/args.py
index f5909e40..8093c884 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -183,6 +183,20 @@ class SvnImport(schema.Strict):
asf_uid: str
+class SvnPublish(schema.Strict):
+ """Arguments for the task to publish a release preview to SVN."""
+
+ asf_uid: str = schema.description("ASF UID of the user initiating publication")
+ project_key: safe.ProjectKey = schema.description("Project key in ATR")
+ version_key: safe.VersionKey = schema.description("Version key in ATR")
+ revision_number: safe.RevisionNumber = schema.description("Preview revision number to publish")
+ download_path_suffix: safe.OptionalRelPath = pydantic.Field(
+ default=None,
+ description="Optional path suffix appended under the committee downloads directory",
+ )
+ target_url: str = schema.description("Fully constructed SVN target URL the task will import to")
+
+
class Update(schema.Strict):
"""Arguments for the task to update metadata from remote data sources."""
diff --git a/atr/models/results.py b/atr/models/results.py
index 8a92a590..95e36c13 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -223,6 +223,15 @@ class SvnImportFiles(schema.Strict):
msg: str = schema.description("The message from the SVN import")
+class SvnPublish(schema.Strict):
+ """Result of the task to publish a release preview to SVN."""
+
+ kind: Literal["svn_publish"] = schema.Field(alias="kind")
+ svn_revision: int = schema.description("The SVN revision number that the publish landed in")
+ target_url: str = schema.description("The SVN target URL the publish was sent to")
+ message: str = schema.description("A short status message describing the publish outcome")
+
+
class VoteAutoResolve(schema.Strict):
"""Result of the task to automatically resolve a vote."""
@@ -285,6 +294,7 @@ Results = Annotated[
| SBOMQsScore
| SBOMToolScore
| SvnImportFiles
+ | SvnPublish
| VoteAutoResolve
| VoteEndNotify
| VoteInitiate,
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 5e4e4d92..ff6e28c5 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -228,6 +228,7 @@ class TaskType(enum.StrEnum):
SBOM_TOOL_SCORE = "sbom_tool_score"
SIGNATURE_CHECK = "signature_check"
SVN_IMPORT_FILES = "svn_import_files"
+ SVN_PUBLISH = "svn_publish"
TARGZ_INTEGRITY = "targz_integrity"
TARGZ_STRUCTURE = "targz_structure"
VOTE_AUTO_RESOLVE = "vote_auto_resolve"
@@ -282,6 +283,8 @@ class TaskType(enum.StrEnum):
return "Signature check"
case TaskType.SVN_IMPORT_FILES:
return "SVN import"
+ case TaskType.SVN_PUBLISH:
+ return "SVN publish"
case TaskType.TARGZ_INTEGRITY:
return "Targz integrity"
case TaskType.TARGZ_STRUCTURE:
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 5b7bba89..4956af8f 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -746,9 +746,9 @@ class CommitteeParticipant(FoundationCommitter):
status=400,
)
- async def upload_file(self, args: api.ReleaseUploadArgs) -> sql.Revision | sql.Quarantined:
- file_bytes = base64.b64decode(args.content, validate=True)
- validated_path = args.relpath.as_path()
+ async def upload_file(self, upload_args: api.ReleaseUploadArgs) -> sql.Revision | sql.Quarantined:
+ file_bytes = base64.b64decode(upload_args.content, validate=True)
+ validated_path = upload_args.relpath.as_path()
description = f"Upload via API: {validated_path}"
async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
@@ -758,8 +758,8 @@ class CommitteeParticipant(FoundationCommitter):
await f.write(file_bytes)
result = await self.__write_as.revision.create_revision_with_quarantine(
- args.project,
- args.version,
+ upload_args.project,
+ upload_args.version,
self.__asf_uid,
allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
description=description,
@@ -768,7 +768,7 @@ class CommitteeParticipant(FoundationCommitter):
if isinstance(result, sql.Quarantined):
return result
async with db.session() as data:
- release_key = sql.release_key(args.project, args.version)
+ release_key = sql.release_key(upload_args.project, upload_args.version)
return await data.revision(
release_key=str(release_key),
number=result.number,
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index b8ce956b..0b3b7034 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -352,6 +352,8 @@ def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results
return signature.check
case sql.TaskType.SVN_IMPORT_FILES:
return svn.import_files
+ case sql.TaskType.SVN_PUBLISH:
+ raise NotImplementedError("The SVN_PUBLISH task handler is not yet implemented")
case sql.TaskType.TARGZ_INTEGRITY:
raise ValueError("TARGZ_INTEGRITY check has been removed; quarantine extraction validates integrity")
case sql.TaskType.TARGZ_STRUCTURE:
diff --git a/atr/util.py b/atr/util.py
index 5ae12cad..1e3eb7c7 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -96,6 +96,8 @@ PRIVATE_KEY_UPLOAD_WARNING: Final[str] = (
)
USER_TESTS_ADDRESS: Final[str] = "user-tests@tooling.apache.org"
LISTS_APACHE_TIMEOUT: Final[aiohttp.ClientTimeout] = aiohttp.ClientTimeout(total=30, connect=10)
+SVN_PUBLISH_PATH_PRODUCTION: Final[str] = "/repos/dist/release"
+SVN_PUBLISH_PATH_TEST: Final[str] = "/repos/dist/atr"
EXPECTED_DEFAULT_TLS_CHECK_HOSTNAME: Final[bool] = True
EXPECTED_DEFAULT_TLS_MINIMUM_VERSION: Final[ssl.TLSVersion] = ssl.TLSVersion.TLSv1_2
EXPECTED_DEFAULT_TLS_VERIFY_MODE: Final[ssl.VerifyMode] = ssl.CERT_REQUIRED
@@ -1009,6 +1011,35 @@ def submitted_concerns_from_flash(flash_data: dict[str, Any]) -> list[str]:
return []
+def svn_production_target_url(url: str) -> bool:
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme not in {"http", "https"}:
+ raise ValueError(f"SVN_PUBLISH_URL must use http or https, got {parsed.scheme!r}")
+ if not parsed.netloc:
+ raise ValueError("SVN_PUBLISH_URL must have a host")
+ path = parsed.path.rstrip("/")
+ if (path == SVN_PUBLISH_PATH_PRODUCTION) or path.startswith(SVN_PUBLISH_PATH_PRODUCTION + "/"):
+ return True
+ if (path == SVN_PUBLISH_PATH_TEST) or path.startswith(SVN_PUBLISH_PATH_TEST + "/"):
+ return False
+ raise ValueError(
+ f"SVN_PUBLISH_URL path must be under {SVN_PUBLISH_PATH_PRODUCTION} or {SVN_PUBLISH_PATH_TEST}, got {path!r}"
+ )
+
+
+def svn_publish_target_url(
+ committee: sql.Committee,
+ download_path_suffix: safe.RelPath | None,
+) -> str:
+ publish_url = config.get().SVN_PUBLISH_URL
+ if not publish_url:
+ raise ValueError("SVN_PUBLISH_URL is not configured")
+ relpath = paths.committee_downloads_dir(committee).path.relative_to(paths.get_downloads_dir().path)
+ if download_path_suffix is not None:
+ relpath = relpath / download_path_suffix.as_path()
+ return f"{publish_url.rstrip('/')}/{relpath}"
+
+
async def task_archive_url(task_mid: str, recipient: str | None = None) -> str | None:
try:
return await task_archive_url_strict(task_mid, recipient)
diff --git a/tests/unit/test_svn_publish_helpers.py b/tests/unit/test_svn_publish_helpers.py
new file mode 100644
index 00000000..2cbd4f19
--- /dev/null
+++ b/tests/unit/test_svn_publish_helpers.py
@@ -0,0 +1,80 @@
+# 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 pytest
+
+import atr.config as config
+import atr.models.args as args
+import atr.models.results as results
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.util as util
+
+
+def test_svn_production_target_url() -> None:
+ assert util.svn_production_target_url("https://dist.apache.org/repos/dist/release/") is True
+ assert util.svn_production_target_url("https://dist.apache.org/repos/dist/atr/") is False
+
+
+def test_svn_production_target_url_rejects_unsupported_urls() -> None:
+ for url in (
+ "svn://dist.apache.org/repos/dist/atr",
+ "https://dist.apache.org/repos/dist/dev/",
+ "https://example.invalid/somewhere",
+ ):
+ with pytest.raises(ValueError):
+ util.svn_production_target_url(url)
+
+
+def test_svn_publish_models_round_trip() -> None:
+ task_args = args.SvnPublish(
+ asf_uid="alice",
+ project_key="apple",
+ version_key="1.0",
+ revision_number="00001",
+ target_url="https://dist.apache.org/repos/dist/atr/apple",
+ )
+ result = results.SvnPublish(
+ kind="svn_publish",
+ svn_revision=12345,
+ target_url=task_args.target_url,
+ message="ok",
+ )
+ restored = results.ResultsAdapter.validate_python(result.model_dump())
+
+ assert str(task_args.project_key) == "apple"
+ assert task_args.download_path_suffix is None
+ assert isinstance(restored, results.SvnPublish)
+ assert restored.svn_revision == 12345
+ assert sql.TaskType.SVN_PUBLISH.label == "SVN publish"
+
+
+def test_svn_publish_target_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", "https://dist.apache.org/repos/dist/atr")
+ committee = sql.Committee(key="apple", name="Apple", is_podling=False)
+
+ target_url = util.svn_publish_target_url(committee, safe.RelPath("apple-1.0"))
+
+ assert target_url == "https://dist.apache.org/repos/dist/atr/apple/apple-1.0"
+
+
+def test_svn_publish_target_url_rejects_missing_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(config.get(), "SVN_PUBLISH_URL", None)
+ committee = sql.Committee(key="apple", name="Apple", is_podling=False)
+
+ with pytest.raises(ValueError):
+ util.svn_publish_target_url(committee, None)
diff --git a/tests/unit/test_svn_publish_tasks_db.py b/tests/unit/test_svn_publish_tasks_db.py
new file mode 100644
index 00000000..19167a86
--- /dev/null
+++ b/tests/unit/test_svn_publish_tasks_db.py
@@ -0,0 +1,194 @@
+# 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 datetime
+from collections.abc import AsyncIterator
+from typing import Final
+
+import pytest
+import sqlalchemy
+import sqlalchemy.ext.asyncio
+import sqlmodel
+
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.models.safe as safe
+import atr.models.sql as sql
+
+TARGET_URL: Final[str] = "https://dist.apache.org/repos/dist/atr/project"
+OTHER_URL: Final[str] = "https://dist.apache.org/repos/dist/atr/other"
+PROJECT: Final[safe.ProjectKey] = safe.ProjectKey("project")
+VERSION: Final[safe.VersionKey] = safe.VersionKey("1.0.0")
+REVISION: Final[safe.RevisionNumber] = safe.RevisionNumber("00001")
+
+
+@pytest.fixture
+async def sqlite_sessionmaker() -> AsyncIterator[sqlalchemy.ext.asyncio.async_sessionmaker[db.Session]]:
+ engine = sqlalchemy.ext.asyncio.create_async_engine(
+ "sqlite+aiosqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=sqlalchemy.pool.StaticPool,
+ )
+ async with engine.begin() as conn:
+ await conn.run_sync(sqlmodel.SQLModel.metadata.create_all)
+ sessionmaker = sqlalchemy.ext.asyncio.async_sessionmaker(bind=engine, class_=db.Session, expire_on_commit=False)
+ yield sessionmaker
+ await engine.dispose()
+
+
+async def test_completed_publish_for_revision_returns_latest_valid_result(sqlite_sessionmaker) -> None:
+ async with sqlite_sessionmaker() as data:
+ data.add(
+ _publish_task(
+ target_url=TARGET_URL,
+ status=sql.TaskStatus.COMPLETED,
+ added=datetime.datetime(2026, 5, 1, tzinfo=datetime.UTC),
+ )
+ )
+ malformed = _publish_task(
+ target_url=OTHER_URL,
+ status=sql.TaskStatus.COMPLETED,
+ added=datetime.datetime(2026, 5, 2, tzinfo=datetime.UTC),
+ )
+ malformed.result = {
+ "kind": "svn_publish",
+ "target_url": OTHER_URL,
+ "message": "missing revision",
+ }
+ data.add(malformed)
+ await data.commit()
+
+ result = await interaction.release_completed_svn_publish_task_for_revision(
+ PROJECT, VERSION, REVISION, caller_data=data
+ )
+
+ assert result is not None
+ assert result.task_args["target_url"] == TARGET_URL
+
+
+async def test_in_flight_publish_returns_newest_queued_or_active_task(sqlite_sessionmaker) -> None:
+ async with sqlite_sessionmaker() as data:
+ data.add(
+ _publish_task(
+ target_url=OTHER_URL,
+ status=sql.TaskStatus.ACTIVE,
+ added=datetime.datetime(2026, 5, 1, tzinfo=datetime.UTC),
+ )
+ )
+ data.add(
+ _publish_task(
+ target_url=TARGET_URL,
+ status=sql.TaskStatus.QUEUED,
+ added=datetime.datetime(2026, 5, 2, tzinfo=datetime.UTC),
+ )
+ )
+ data.add(
+ _publish_task(
+ target_url=TARGET_URL,
+ status=sql.TaskStatus.FAILED,
+ added=datetime.datetime(2026, 5, 3, tzinfo=datetime.UTC),
+ )
+ )
+ await data.commit()
+
+ latest = await interaction.release_in_flight_svn_publish_task(PROJECT, VERSION, REVISION, caller_data=data)
+ other_target = await interaction.release_in_flight_svn_publish_task(
+ PROJECT, VERSION, REVISION, OTHER_URL, caller_data=data
+ )
+
+ assert latest is not None
+ assert latest.task_args["target_url"] == TARGET_URL
+ assert other_target is not None
+ assert other_target.status == sql.TaskStatus.ACTIVE
+
+
+async def test_latest_failed_publish_returns_failed_task(sqlite_sessionmaker) -> None:
+ async with sqlite_sessionmaker() as data:
+ data.add(
+ _publish_task(
+ target_url=TARGET_URL,
+ status=sql.TaskStatus.QUEUED,
+ added=datetime.datetime(2026, 5, 1, tzinfo=datetime.UTC),
+ )
+ )
+ data.add(
+ _publish_task(
+ target_url=TARGET_URL,
+ status=sql.TaskStatus.FAILED,
+ added=datetime.datetime(2026, 5, 2, tzinfo=datetime.UTC),
+ )
+ )
+ await data.commit()
+
+ result = await interaction.release_latest_failed_svn_publish_task(
+ PROJECT, VERSION, REVISION, TARGET_URL, caller_data=data
+ )
+
+ assert result is not None
+ assert result.status == sql.TaskStatus.FAILED
+
+
+def _publish_task(
+ *,
+ target_url: str,
+ status: sql.TaskStatus,
+ added: datetime.datetime,
+ revision_number: str = "00001",
+ project_key: str = "project",
+ version_key: str = "1.0.0",
+) -> sql.Task:
+ completed: datetime.datetime | None = None
+ error: str | None = None
+ pid: int | None = None
+ result: dict[str, object] | None = None
+ started: datetime.datetime | None = None
+ if status == sql.TaskStatus.ACTIVE:
+ started = added
+ pid = 1234
+ elif status == sql.TaskStatus.COMPLETED:
+ completed = added
+ result = {
+ "kind": "svn_publish",
+ "svn_revision": 42,
+ "target_url": target_url,
+ "message": "ok",
+ }
+ elif status == sql.TaskStatus.FAILED:
+ completed = added
+ error = "publish failed"
+ return sql.Task(
+ status=status,
+ task_type=sql.TaskType.SVN_PUBLISH,
+ task_args={
+ "asf_uid": "alice",
+ "project_key": project_key,
+ "version_key": version_key,
+ "revision_number": revision_number,
+ "download_path_suffix": None,
+ "target_url": target_url,
+ },
+ asf_uid="alice",
+ project_key=project_key,
+ version_key=version_key,
+ revision_number=revision_number,
+ added=added,
+ started=started,
+ pid=pid,
+ completed=completed,
+ result=result,
+ error=error,
+ )
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org