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
38 changes: 23 additions & 15 deletions backend/druks/contrib/software_factory/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from druks.contrib.software_factory.ticketing.base import Tracker
from druks.contrib.software_factory.ticketing.druks import DruksTracker
from druks.contrib.software_factory.ticketing.enums import TicketStatus
from druks.contrib.software_factory.ticketing.github import GitHub
from druks.contrib.software_factory.ticketing.jira import Jira
from druks.contrib.software_factory.ticketing.linear import Linear
from druks.core import services
Expand All @@ -37,7 +38,12 @@ async def check_tracker_identity() -> CheckResult:
return CheckResult(name="tracker", ok=True, detail="trackerless by choice")
if settings.tracker == "druks":
return CheckResult(name="tracker", ok=True, detail="this appliance")
service = {"linear": services.Linear, "jira": services.Jira}[settings.tracker]
service = {
"linear": services.Linear,
"jira": services.Jira,
# The GitHub tracker acts as the operator App, so it needs no credential of its own.
"github": services.Github,
}[settings.tracker]
if await service.is_connected():
return CheckResult(name="tracker", ok=True, detail=f"{settings.tracker} connected")
return CheckResult(
Expand All @@ -52,14 +58,13 @@ async def check_tracker_identity() -> CheckResult:
async def list_tracker_status_choices() -> list[dict[str, str]]:
"""The statuses of the selected tracker. Empty when no connected tracker lists them."""
tracker = await SoftwareFactory.get_tracker()
if not tracker:
return []
async with tracker:
try:
return await tracker.list_status_choices()
except tracker.known_exceptions:
logger.warning("Could not list the tracker statuses.", exc_info=True)
return []
if tracker:
async with tracker:
try:
return await tracker.list_status_choices()
except tracker.known_exceptions:
logger.warning("Could not list the tracker statuses.", exc_info=True)
return []


async def check_review_identity() -> CheckResult:
Expand All @@ -86,7 +91,7 @@ class SoftwareFactory(App):
)

class Settings(AppSettings):
tracker: Literal["none", "linear", "jira", "druks"] = Field(
tracker: Literal["none", "linear", "jira", "druks", "github"] = Field(
default="linear",
title="Tracker",
description="Which ticket tracker this installation uses.",
Expand All @@ -97,7 +102,7 @@ class Settings(AppSettings):
description="A ticket entering this status opens a build.",
json_schema_extra={
"section": "Statuses",
"visible_when": {"tracker": ["linear", "jira"]},
"visible_when": {"tracker": ["linear", "jira", "github"]},
},
)
in_progress_status: Annotated[str, Choices(list_tracker_status_choices)] = Field(
Expand All @@ -106,7 +111,7 @@ class Settings(AppSettings):
description="The status of a ticket while a build works on it.",
json_schema_extra={
"section": "Statuses",
"visible_when": {"tracker": ["linear", "jira"]},
"visible_when": {"tracker": ["linear", "jira", "github"]},
},
)
in_review_status: Annotated[str, Choices(list_tracker_status_choices)] = Field(
Expand All @@ -116,7 +121,7 @@ class Settings(AppSettings):
"Empty leaves the ticket where it is.",
json_schema_extra={
"section": "Statuses",
"visible_when": {"tracker": ["linear", "jira"]},
"visible_when": {"tracker": ["linear", "jira", "github"]},
},
)
done_status: Annotated[str, Choices(list_tracker_status_choices)] = Field(
Expand All @@ -125,7 +130,7 @@ class Settings(AppSettings):
description="The status of a ticket after its pull request merges.",
json_schema_extra={
"section": "Statuses",
"visible_when": {"tracker": ["linear", "jira"]},
"visible_when": {"tracker": ["linear", "jira", "github"]},
},
)
resting_status: Annotated[str, Choices(list_tracker_status_choices)] = Field(
Expand All @@ -135,7 +140,7 @@ class Settings(AppSettings):
"Empty leaves the ticket where it is.",
json_schema_extra={
"section": "Statuses",
"visible_when": {"tracker": ["linear", "jira"]},
"visible_when": {"tracker": ["linear", "jira", "github"]},
},
)

Expand Down Expand Up @@ -169,6 +174,9 @@ async def get_tracker(cls, source: str | None = None) -> Tracker | None:
api_token=row.secrets["api_token"],
status_names=status_names,
)
if settings.tracker == "github":
client = await services.Github.get_client()
return GitHub(client=client, status_names=status_names)
except ServiceNotConnectedError:
return

Expand Down
3 changes: 2 additions & 1 deletion backend/druks/contrib/software_factory/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ class TrackerNotConfigured(AgentApiError):
def __init__(self) -> None:
super().__init__(
"No ticket tracker is configured. Select one in the Software Factory settings. "
"Linear and Jira also need their identity in Settings → Connections → Services."
"Linear, Jira, and GitHub also need their identity in "
"Settings → Connections → Services."
)


Expand Down
5 changes: 3 additions & 2 deletions backend/druks/contrib/software_factory/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,8 @@ async def list_work_items_history(


@work_items_router.post(
"/{ticket}/start",
# A GitHub key, ``owner/repo#12``, has a ``/`` that would end a plain segment.
"/{ticket:path}/start",
status_code=status.HTTP_202_ACCEPTED,
operation_id="start",
tags=["agent"],
Expand All @@ -290,7 +291,7 @@ async def list_work_items_history(
async def start_work_item(
ticket: str = Path(
...,
description="The tracker's ticket key, e.g. ENG-831.",
description="The tracker's ticket key, e.g. ENG-831, or owner/repo#12 for GitHub.",
),
account: Account = Depends(current_account),
) -> None:
Expand Down
26 changes: 25 additions & 1 deletion backend/druks/contrib/software_factory/subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from druks.contrib.software_factory.ticketing.enums import TicketStatus
from druks.contrib.software_factory.workflows import Build, Profile
from druks.models import Base
from druks.signals import subscribe
from druks.signals import publish, subscribe
from druks.workflows import WorkflowEvent


Expand Down Expand Up @@ -78,6 +78,30 @@ async def pr_close_settles_the_item(*, repo: str, pr_number: int, payload: dict)
await item.resolve(resolution, at=payload["resolved_at"])


@subscribe("issue.labeled")
async def issue_label_transitions_the_ticket(*, repo: str, number: int, payload: dict) -> None:
"""GitHub has no status field, so a label is the status of a GitHub ticket."""
assignee_id = payload["assignee_id"]
await publish(
"ticket.transitioned",
payload={
"source": "github",
# Issue numbers repeat across repos, so the key carries the repo.
"identifier": f"{repo}#{number}",
"status": payload["label"],
"title": payload["title"],
"url": payload["url"],
# The issue's own repo is the build's repo, so no label routes it elsewhere.
"project_name": repo,
"labels": [],
"actor_id": str(payload["actor_id"]),
"assignee_id": str(assignee_id) if assignee_id else None,
"assignee_email": None,
"assignee_name": payload["assignee"],
},
)


@subscribe("ticket.transitioned")
async def ticket_transition_drives_the_funnel(*, payload: dict) -> None:
"""Dispatch a build when a ticket from the chosen tracker enters its trigger status."""
Expand Down
38 changes: 38 additions & 0 deletions backend/druks/contrib/software_factory/ticketing/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from githubkit.exception import GitHubException

from druks.core.apis.exceptions import GitHubAppNotInstalledError, UnknownTicketError
from druks.core.apis.github import GITHUB_AUTHORITY, GitHubClient

from .base import Tracker
from .enums import TicketStatus


class GitHub(Tracker):
"""GitHub has no status field, so each status name is a label. The status labels
are one exclusive group: setting one takes the others off, the trigger label too,
so adding the trigger label again is a new event."""

known_exceptions = (GitHubException, GitHubAppNotInstalledError, UnknownTicketError)
authority = GITHUB_AUTHORITY

def __init__(self, *, client: GitHubClient, status_names: dict[TicketStatus, str]) -> None:
self._client = client
# An empty name leaves that status unmapped.
self._status_names = {status: name for status, name in status_names.items() if name}

async def set_status(self, key: str, status: TicketStatus) -> None:
name = self._status_names.get(status)
if not name:
raise ValueError(f"GitHub has no configured label for {status}")
# Issue numbers repeat across repositories, so the key is ``owner/repo#number``.
repo, _, number = key.partition("#")
if "/" not in repo or not number.isdigit():
raise UnknownTicketError(key, "GitHub")
others = {label for label in self._status_names.values() if label != name}
await self._client.replace_issue_labels(repo, int(number), add=name, remove=others)
if status is TicketStatus.DONE:
# Finished work on GitHub is a closed issue, not only a label.
await self._client.close_issue(repo, int(number))

async def aclose(self) -> None:
await self._client.aclose()
24 changes: 24 additions & 0 deletions backend/druks/core/apis/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,30 @@ async def reply_to_review_comment(
)
return response.parsed_data.model_dump()

@_retry_on_401
async def replace_issue_labels(
self, repo: str, issue_number: int, *, add: str, remove: set[str]
) -> None:
"""Put ``add`` on the issue and take ``remove`` off. GitHub answers 404 for a
label the issue does not carry, and that label is already off."""
owner, name = repo.split("/", 1)
github = await self._for_repo(repo)
await github.rest.issues.async_add_labels(owner, name, issue_number, data=[add])
for label in remove:
try:
await github.rest.issues.async_remove_label(owner, name, issue_number, label)
except RequestFailed as error:
if error.response.status_code != 404:
raise

@_retry_on_401
async def close_issue(self, repo: str, issue_number: int) -> None:
owner, name = repo.split("/", 1)
github = await self._for_repo(repo)
await github.rest.issues.async_update(
owner, name, issue_number, state="closed", state_reason="completed"
)

@_retry_on_401
async def get_pull_request(self, repo: str, pr_number: int) -> dict[str, Any]:
owner, name = repo.split("/", 1)
Expand Down
1 change: 1 addition & 0 deletions backend/druks/core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class Github(Service):
"public": False,
"default_events": [
"issue_comment",
"issues",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
Expand Down
23 changes: 23 additions & 0 deletions backend/druks/core/webhooks/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,29 @@ async def _publish_comment(
},
)

async def on_issues_labeled(self) -> Response:
"""Publish a label that a person or this App put on an issue. Another App's
label acts for no one here."""
sender, issue = self.data["sender"], self.data["issue"]
# GitHub names an App's bot user after the App's slug.
own_bot = f"{(await Github.get()).identity['slug']}[bot]"
if sender["type"] == "User" or sender["login"] == own_bot:
assignee = issue.get("assignee") or {}
await publish(
"issue.labeled",
repo=_repo_name(self.data),
number=issue["number"],
payload={
"label": self.data["label"]["name"],
"actor_id": sender["id"],
"title": issue["title"],
"url": issue["html_url"],
"assignee": assignee.get("login"),
"assignee_id": assignee.get("id"),
},
)
return _accepted()

async def on_pull_request_closed(self) -> Response:
pull_request = self.data["pull_request"]
merged = pull_request["merged"]
Expand Down
Loading
Loading