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
2 changes: 1 addition & 1 deletion backend/druks/contrib/ship/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from pydantic import Field

from druks.agents import Agent
from druks.contrib.ship import services
from druks.contrib.ship.contracts import (
CodeReviewOutput,
ContractRevisionOutput,
Expand All @@ -17,6 +16,7 @@
from druks.contrib.ship.ticketing.base import Tracker
from druks.contrib.ship.ticketing.jira import Jira
from druks.contrib.ship.ticketing.linear import Linear
from druks.core import services
from druks.db import StoredSubject
from druks.doctor import CheckResult
from druks.extensions import Extension, ExtensionSettings
Expand Down
2 changes: 1 addition & 1 deletion backend/druks/contrib/ship/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
WorkItemsHistoryResponse,
)
from druks.contrib.ship.ticketing.enums import TicketStatus
from druks.contrib.ship.ticketing.exceptions import UnknownTicketError
from druks.contrib.ship.workflows import Profile
from druks.core.apis.exceptions import UnknownTicketError
from druks.core.apis.github import get_github_client
from druks.db import db_session
from druks.services.exceptions import ServiceNotConnectedError
Expand Down
74 changes: 0 additions & 74 deletions backend/druks/contrib/ship/services.py

This file was deleted.

26 changes: 0 additions & 26 deletions backend/druks/contrib/ship/ticketing/exceptions.py

This file was deleted.

71 changes: 3 additions & 68 deletions backend/druks/contrib/ship/ticketing/jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,11 @@

import httpx

from druks.core.apis.exceptions import JiraAPIError, UnknownTicketError
from druks.core.apis.jira import JiraClient

from .base import Tracker
from .enums import TicketStatus
from .exceptions import JiraAPIError, UnknownTicketError

_DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=5.0, write=10.0, pool=5.0)
_DEFAULT_LIMITS = httpx.Limits(max_connections=20, max_keepalive_connections=10)


class JiraClient:
def __init__(
self,
*,
base_url: str,
email: str,
api_token: str,
client: httpx.AsyncClient | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self._client = client or httpx.AsyncClient(
timeout=_DEFAULT_TIMEOUT,
limits=_DEFAULT_LIMITS,
auth=httpx.BasicAuth(email, api_token),
headers={"Accept": "application/json", "Content-Type": "application/json"},
)

async def aclose(self) -> None:
await self._client.aclose()

async def _request(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
) -> dict[str, Any]:
response = await self._client.request(method, f"{self.base_url}{path}", json=json)
if not response.is_success:
raise JiraAPIError(
f"{method} {path} -> {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
if response.status_code == 204 or not response.content:
return {}
return response.json()

async def transition_issue(self, key: str, status_name: str) -> None:
# Jira moves status only via transitions: find the one whose target is
# the requested status, then execute it.
try:
data = await self._request("GET", f"/rest/api/3/issue/{key}/transitions")
except JiraAPIError as error:
# The transitions lookup 404s only when the issue itself is unknown.
if error.status_code == 404:
raise UnknownTicketError(key, "Jira") from error
raise
transition_id = next(
(
transition["id"]
for transition in data["transitions"]
if transition["to"]["name"] == status_name
),
None,
)
if not transition_id:
raise JiraAPIError(f"{key} has no transition to status {status_name!r}")
await self._request(
"POST",
f"/rest/api/3/issue/{key}/transitions",
json={"transition": {"id": transition_id}},
)


class Jira(Tracker):
Expand Down
136 changes: 3 additions & 133 deletions backend/druks/contrib/ship/ticketing/linear.py
Original file line number Diff line number Diff line change
@@ -1,142 +1,12 @@
import hashlib
from typing import Any

import httpx

from druks.core.apis.exceptions import LinearAPIError, UnknownTicketError
from druks.core.apis.linear import LinearClient

from .base import Tracker
from .enums import TicketStatus
from .exceptions import LinearAPIError, UnknownTicketError

LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"


def compute_delivery_key(
headers: dict[str, str],
raw_body: bytes,
payload: dict[str, Any],
) -> str:
delivery_id = headers.get("linear-delivery")
if delivery_id:
return delivery_id

action = str(payload.get("action", ""))
issue_data = payload.get("data", {})
issue_id = str(issue_data.get("id", ""))
updated_at = str(issue_data.get("updatedAt", ""))
body_digest = hashlib.sha256(raw_body).hexdigest()[:16]
composite = f"{action}:{issue_id}:{updated_at}:{body_digest}"
return hashlib.sha256(composite.encode()).hexdigest()


# Granular timeouts: short connect/write phases, longer read for slow Linear
# responses, bounded pool wait so a saturated pool fails fast instead of
# stalling the request indefinitely.
_DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=5.0, write=10.0, pool=5.0)
_DEFAULT_LIMITS = httpx.Limits(max_connections=20, max_keepalive_connections=10)


class LinearClient:
def __init__(
self,
*,
api_key: str,
api_url: str = LINEAR_GRAPHQL_URL,
client: httpx.AsyncClient | None = None,
) -> None:
self.api_key = api_key
self.api_url = api_url
# One long-lived AsyncClient per LinearClient instance — pools
# connections across the many GraphQL calls a single build run
# makes. Tests inject a stub client; production builds the default.
self._client = client or httpx.AsyncClient(
timeout=_DEFAULT_TIMEOUT,
limits=_DEFAULT_LIMITS,
headers={
"Authorization": api_key,
"Content-Type": "application/json",
},
)

async def aclose(self) -> None:
await self._client.aclose()

async def update_issue_status(self, issue_id: str, status_name: str) -> dict[str, Any]:
data = await self._execute(
"""
query DruksIssueWorkflowStates($issueId: String!) {
issue(id: $issueId) {
id
identifier
state { id name }
team {
states {
nodes { id name }
}
}
}
}
""",
{"issueId": issue_id},
)
issue = data["issue"]
# Linear answers an unknown identifier with a null issue, not an error.
if issue is None:
raise UnknownTicketError(issue_id, "Linear")
current_status = issue["state"]["name"]
if current_status == status_name:
return {
"identifier": issue["identifier"],
"status": current_status,
"changed": False,
}

status_id = _status_id_by_name(issue["team"]["states"]["nodes"], status_name)
result = await self._execute(
"""
mutation DruksIssueUpdateStatus($issueId: String!, $statusId: String!) {
issueUpdate(id: $issueId, input: { stateId: $statusId }) {
success
issue {
identifier
state { name }
}
}
}
""",
{"issueId": issue_id, "statusId": status_id},
)
issue_result = result["issueUpdate"]["issue"]
return {
"identifier": issue_result["identifier"],
"status": issue_result["state"]["name"],
"changed": bool(result["issueUpdate"]["success"]),
}

async def _execute(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
response = await self._client.post(
self.api_url,
json={"query": query, "variables": variables},
)
response.raise_for_status()
body = response.json()
errors = body.get("errors")
if errors:
raise LinearAPIError(f"Linear API returned errors: {errors}")

data = body.get("data")
if not isinstance(data, dict):
raise LinearAPIError("Linear API response did not include data.")

return data


def _status_id_by_name(states: list[dict[str, Any]], status_name: str) -> str:
for state in states:
if state["name"] == status_name:
return state["id"]

available = ", ".join(state["name"] for state in states)
raise LinearAPIError(f"Linear status {status_name!r} was not found. Available: {available}")


class Linear(Tracker):
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/contrib/ship/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from fastapi import HTTPException, status
from fastapi.responses import JSONResponse, Response

from druks.contrib.ship import services
from druks.contrib.ship.ticketing.linear import compute_delivery_key
from druks.core import services
from druks.core.apis.linear import compute_delivery_key
from druks.services import ServiceNotConnectedError
from druks.signals import publish
from druks.webhooks import Webhook, verify_hmac_sha256
Expand Down
28 changes: 28 additions & 0 deletions backend/druks/core/apis/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,31 @@ def __init__(self, repo: str) -> None:
"repo (or add it to the installation's selected repositories)."
)
self.repo = repo


class LinearAPIError(Exception):
"""Raised when Linear's GraphQL endpoint returns a logical error.

Distinct from ``httpx.HTTPError`` (transport / HTTP-status failures)
so callers can catch both failure classes precisely.
"""


class JiraAPIError(Exception):
"""Jira REST returned a non-2xx response. Distinct from ``httpx.HTTPError``
(transport) so callers can ``except (httpx.HTTPError, JiraAPIError)``."""

def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code


class UnknownTicketError(Exception):
"""The tracker has no ticket under the given key. Each provider raises it
from its own not-found signal, so callers can answer "that ticket doesn't
exist" without knowing which provider spoke."""

def __init__(self, key: str, tracker: str) -> None:
super().__init__(f"{key} doesn't exist in {tracker}")
self.key = key
self.tracker = tracker
Loading