Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Should the analysis fail, for whatever reason, an error is posted to the same

The endpoint expects a JSON payload matching the `BuildInfo` model:

```
```json
{
"artifacts": {
"builder-live.log": "http://example.com/builder-live.log",
Expand All @@ -25,11 +25,15 @@ The endpoint expects a JSON payload matching the `BuildInfo` model:
"build_system": "copr",
"commit_sha": "9deb98c730bb4123f518ca13a0dbec5d7c0669ca",
"project_url": "www.logdetective.com",
"pr_id": 1
"pr_id": 1,
"build_metadata": {
"commentary": "I've made a terrible mistake",
"infra_status": "BROKEN"
}
}
```

artifacts (dict): A dictionary mapping log filenames to their full URL.
artifacts (dict): A dictionary mapping log filenames to their full URL or raw contents.

target_build (str): A unique identifier for the build, which will be included in the message.

Expand All @@ -41,7 +45,17 @@ project_url (str, optional): URL of the project the build is for

pr_id (int, optional): Identifier of the pull request, or equivalent

Of these values, only `artifacts` are used by Log Detective itself.
build_metadata: (dict, optional): Dictionary of additional information about concluded build:

specfile (str, optional): Contents of package spec file

last_patch (str, optional): The last patch applied as a string

commentary (str, optional): Additional relevant information, such as PR description

infra_status (str, optional): Infrastructure status

Of these values, only `artifacts` and `build_metadata` are used by Log Detective itself.
The rest is used as part of a message sent to Fedora Messaging infrastructure,
to identify results.

Expand All @@ -58,13 +72,13 @@ Additionally, for Sentry error and performance monitoring, `LD_PACKIT_INTERFACE_
Images are published to quay.io. If it isn't available, or if you want
to test your own changes. First build your own image

```
```bash
podman build -t logdetective-packit .
```

and then run the container:

```
```bash
podman run -d --name logdetective-packit \
-p 8090:8090 \
-e LD_URL="https://logdetective.example.com/api" \
Expand Down Expand Up @@ -108,6 +122,6 @@ When using uv, this is handled automatically via `[tool.uv.sources]` in pyprojec

Tests should be executed with `uv`:

```
```bash
uv run pytest
```
27 changes: 22 additions & 5 deletions src/logdetective_packit/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from logdetective_packit_message import LogDetectiveResult, LogDetectiveMessage

from logdetective_packit.models import BuildInfo, Response
from logdetective_packit.utils import is_url

LD_URL = os.environ.get("LD_URL")
LD_TOKEN = os.environ.get("LD_TOKEN", "")
Expand Down Expand Up @@ -98,11 +99,27 @@ async def call_log_detective(
log_detective_analysis_id: str,
log_detective_analysis_start: datetime,
) -> None:
"""Analyze build artifacts using Log Detective API. Only the first log
is analyzed."""
build_artifacts = list(build_info.artifacts.items())
Comment thread
jpodivin marked this conversation as resolved.
log_url = build_artifacts[0][1]
"""Analyze build artifacts using Log Detective API."""

headers = {}
files = []
analysis_request = {}

for artifact_identity, artifact_content in build_info.artifacts.items():
if is_url(artifact_content):
files.append({"name": artifact_identity, "url": artifact_content})
else:
files.append(
{
"name": artifact_identity,
"content": artifact_content,
}
)

analysis_request["files"] = files

if build_info.build_metadata:
analysis_request["build_metadata"] = build_info.build_metadata.model_dump()

# If Log Detective server requires authorization
if LD_TOKEN:
Expand All @@ -111,7 +128,7 @@ async def call_log_detective(
response = await http_client.post(
url=LD_URL,
headers=headers,
json={"url": log_url},
json=analysis_request,
)
response.raise_for_status()
except HTTPStatusError as ex:
Expand Down
21 changes: 21 additions & 0 deletions src/logdetective_packit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
from pydantic import BaseModel, Field


class BuildMetadata(BaseModel):
"""Model of additional information provided about the build."""

specfile: Optional[str] = Field(
description="Contents of package spec file as a string.", default=None
)
last_patch: Optional[str] = Field(
description="Contents of last patch applied as a string.", default=None
)
commentary: Optional[str] = Field(
description="Comment attached to the triggered build, such as PR description.",
default=None,
)
infra_status: Optional[str] = Field(
description="State of build infrastructure as a string.", default=None
)
Comment thread
jpodivin marked this conversation as resolved.


class BuildInfo(BaseModel):
"""ID of the build being analyzed and URL to and all artifacts."""

Expand All @@ -24,6 +42,9 @@ class BuildInfo(BaseModel):
description="ID of the pull request, or equivalent of the given forge",
default=None,
)
build_metadata: Optional[BuildMetadata] = Field(
description="Optional build metadata.", default=None
)


class Response(BaseModel):
Expand Down
9 changes: 9 additions & 0 deletions src/logdetective_packit/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from urllib.parse import urlparse


def is_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
29 changes: 23 additions & 6 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
publish_message,
PUBLISH_TIMEOUT,
)
from logdetective_packit.utils import is_url


from tests.utils import (
Expand Down Expand Up @@ -117,9 +118,10 @@ async def test_publish_message_exceptions(
async def test_call_log_detective(
mock_env_vars, mock_external_calls, mock_server_logger
):

log_detective_analysis_id = "8052517e-cf69-11f0-9b27-9a478821d0e2"
log_detective_build_analysis_start = datetime.fromisoformat("2025-12-10 10:57:57.341695+00:00")
log_detective_build_analysis_start = datetime.fromisoformat(
"2025-12-10 10:57:57.341695+00:00"
)
build_info = BuildInfo(**MINIMAL_BUILD_INFO)
await call_log_detective(
build_info=build_info,
Expand All @@ -144,7 +146,9 @@ async def test_call_log_detective_request_exception(

with pytest.raises(HTTPStatusError):
log_detective_build_analysis_id = "8052517e-cf69-11f0-9b27-9a478821d0e2"
log_detective_build_analysis_start = datetime.fromisoformat("2025-12-10 10:57:57.341695+00:00")
log_detective_build_analysis_start = datetime.fromisoformat(
"2025-12-10 10:57:57.341695+00:00"
)
await call_log_detective(
build_info=build_info,
log_detective_analysis_id=log_detective_build_analysis_id,
Expand All @@ -160,13 +164,18 @@ async def test_analyze_build_skeleton(
):
"""Test for the entire /analyze endpoint."""

# Mock is_url to test calls
mock_is_url = mocker.patch("logdetective_packit.main.is_url", side_effect=is_url)

# Mock the return value of requests.post().json()
mock_response = mocker.Mock()
mock_response.json.return_value = {"status": "analysis_started", "id": "fake-id"}
mock_response.raise_for_status = mocker.Mock()
mock_external_calls["mock_async_client"].post.return_value = mock_response

monkeypatch.setattr("logdetective_packit.main.LD_URL", "http://mock-ld-server.com/api")
monkeypatch.setattr(
"logdetective_packit.main.LD_URL", "http://mock-ld-server.com/api"
)
monkeypatch.setattr("logdetective_packit.main.LD_TOKEN", "test-token-123")
monkeypatch.setattr("logdetective_packit.main.LD_PACKIT_TOKEN", "secret-123")

Expand All @@ -193,8 +202,16 @@ async def test_analyze_build_skeleton(
# Check that requests.post was called correctly
expected_headers = {"Authorization": "Bearer test-token-123"}
# The code only takes the first log URL
expected_data = {"url": "http://example.com/builder-live.log"}

expected_data = {
"files": [
{
"name": "builder-live.log",
"url": "http://example.com/builder-live.log",
}
]
}
Comment thread
jpodivin marked this conversation as resolved.

mock_is_url.assert_called_once_with("http://example.com/builder-live.log")
mock_external_calls["mock_async_client"].post.assert_called_once_with(
url="http://mock-ld-server.com/api",
json=expected_data,
Expand Down
Loading