diff --git a/backend/druks/contrib/software_factory/app.py b/backend/druks/contrib/software_factory/app.py index b03a9b41f..a076609c6 100644 --- a/backend/druks/contrib/software_factory/app.py +++ b/backend/druks/contrib/software_factory/app.py @@ -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 @@ -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( @@ -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: @@ -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.", @@ -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( @@ -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( @@ -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( @@ -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( @@ -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"]}, }, ) @@ -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 diff --git a/backend/druks/contrib/software_factory/exceptions.py b/backend/druks/contrib/software_factory/exceptions.py index 413c14d1d..746806311 100644 --- a/backend/druks/contrib/software_factory/exceptions.py +++ b/backend/druks/contrib/software_factory/exceptions.py @@ -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." ) diff --git a/backend/druks/contrib/software_factory/routes.py b/backend/druks/contrib/software_factory/routes.py index b3ac479ca..da2e8524f 100644 --- a/backend/druks/contrib/software_factory/routes.py +++ b/backend/druks/contrib/software_factory/routes.py @@ -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"], @@ -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: diff --git a/backend/druks/contrib/software_factory/subscribers.py b/backend/druks/contrib/software_factory/subscribers.py index da453a188..7a8ad44b2 100644 --- a/backend/druks/contrib/software_factory/subscribers.py +++ b/backend/druks/contrib/software_factory/subscribers.py @@ -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 @@ -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.""" diff --git a/backend/druks/contrib/software_factory/ticketing/github.py b/backend/druks/contrib/software_factory/ticketing/github.py new file mode 100644 index 000000000..7505df57b --- /dev/null +++ b/backend/druks/contrib/software_factory/ticketing/github.py @@ -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() diff --git a/backend/druks/core/apis/github.py b/backend/druks/core/apis/github.py index b3b34dae5..7903b63ad 100644 --- a/backend/druks/core/apis/github.py +++ b/backend/druks/core/apis/github.py @@ -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) diff --git a/backend/druks/core/services.py b/backend/druks/core/services.py index def13bbb4..d162e756c 100644 --- a/backend/druks/core/services.py +++ b/backend/druks/core/services.py @@ -57,6 +57,7 @@ class Github(Service): "public": False, "default_events": [ "issue_comment", + "issues", "pull_request", "pull_request_review", "pull_request_review_comment", diff --git a/backend/druks/core/webhooks/github.py b/backend/druks/core/webhooks/github.py index 71e1b2b7f..eb85939e4 100644 --- a/backend/druks/core/webhooks/github.py +++ b/backend/druks/core/webhooks/github.py @@ -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"] diff --git a/backend/tests/software_factory/test_ticketing.py b/backend/tests/software_factory/test_ticketing.py index 180fcc3f6..d72a5d62f 100644 --- a/backend/tests/software_factory/test_ticketing.py +++ b/backend/tests/software_factory/test_ticketing.py @@ -11,10 +11,12 @@ ) 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 from druks.core.apis.exceptions import ( + GitHubAppNotInstalledError, JiraAPIError, LinearAPIError, TicketNotMovableError, @@ -24,6 +26,7 @@ from druks.core.apis.linear import LinearClient from druks.secrets.models import VaultSecret from druks.services import ServiceConnectError +from githubkit.exception import GitHubException from software_factory.factories import make_test_work_item @@ -45,6 +48,14 @@ async def _connect_linear(): ) +async def _connect_github(): + return await connect_service( + "github", + identity={"slug": "druks", "app_id": "1"}, + secrets={"private_key": "pem", "webhook_secret": "gh-hook"}, + ) + + async def _connect_jira(): return await connect_service( "jira", @@ -289,11 +300,14 @@ async def test_tracker_check_accepts_the_board_without_a_service(monkeypatch): assert not result.pending -def test_status_settings_show_for_linear_and_jira_with_the_selected_tracker_statuses(): +def test_status_settings_show_for_every_tracker_that_names_its_own_statuses(): fields = SoftwareFactory.Settings.model_fields - assert field_choices(fields["tracker"]) == ["none", "linear", "jira", "druks"] + assert field_choices(fields["tracker"]) == ["none", "linear", "jira", "druks", "github"] for status in ("trigger", "in_progress", "in_review", "done", "resting"): - assert field_visibility(fields[f"{status}_status"]) == ("tracker", ["linear", "jira"]) + assert field_visibility(fields[f"{status}_status"]) == ( + "tracker", + ["linear", "jira", "github"], + ) assert field_live_choices(fields[f"{status}_status"]).source is list_tracker_status_choices @@ -701,3 +715,76 @@ def handler(request: httpx.Request) -> httpx.Response: assert [status["name"] for status in statuses] == ["To Do", "Done"] # The status search endpoint needs Jira admin rights. This one needs Browse projects. assert paths == ["/rest/api/3/status"] + + +class _FakeGitHubClient: + def __init__(self) -> None: + self.calls: list = [] + + async def replace_issue_labels(self, repo, issue_number, *, add, remove): + self.calls.append(("replace_issue_labels", repo, issue_number, add, sorted(remove))) + + async def close_issue(self, repo, issue_number): + self.calls.append(("close_issue", repo, issue_number)) + + +def _github(fake) -> GitHub: + return GitHub( + client=fake, + status_names={ + TicketStatus.TRIGGER: "ready-for-agent", + TicketStatus.IN_PROGRESS: "agent:in-progress", + TicketStatus.IN_REVIEW: "agent:in-review", + TicketStatus.DONE: "agent:done", + TicketStatus.BACKLOG: "", + }, + ) + + +async def test_tracker_builds_github_from_the_operator_app(monkeypatch): + await _connect_github() + _pin_software_factory_settings(monkeypatch, tracker="github", trigger_status="ready-for-agent") + + tracker = await SoftwareFactory.get_tracker("github") + + assert isinstance(tracker, GitHub) + assert tracker._status_names[TicketStatus.TRIGGER] == "ready-for-agent" + + +async def test_github_set_status_swaps_the_label_within_an_exclusive_group(): + fake = _FakeGitHubClient() + + await _github(fake).set_status("acme/widget#7", TicketStatus.IN_PROGRESS) + + assert fake.calls == [ + ( + "replace_issue_labels", + "acme/widget", + 7, + "agent:in-progress", + ["agent:done", "agent:in-review", "ready-for-agent"], + ) + ] + + +async def test_github_done_closes_the_issue(): + fake = _FakeGitHubClient() + + await _github(fake).set_status("acme/widget#7", TicketStatus.DONE) + + assert fake.calls[-1] == ("close_issue", "acme/widget", 7) + + +async def test_github_rejects_a_key_without_its_repo(): + tracker = _github(_FakeGitHubClient()) + + with pytest.raises(UnknownTicketError, match="doesn't exist in GitHub"): + await tracker.set_status("widget#7", TicketStatus.DONE) + with pytest.raises(UnknownTicketError): + await tracker.set_status("acme/widget#seven", TicketStatus.DONE) + + +def test_github_declares_known_exceptions(): + assert GitHubException in GitHub.known_exceptions + assert GitHubAppNotInstalledError in GitHub.known_exceptions + assert UnknownTicketError in GitHub.known_exceptions diff --git a/backend/tests/software_factory/test_webhooks_github_issues.py b/backend/tests/software_factory/test_webhooks_github_issues.py new file mode 100644 index 000000000..d27b974ee --- /dev/null +++ b/backend/tests/software_factory/test_webhooks_github_issues.py @@ -0,0 +1,139 @@ +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import druks.contrib.software_factory.subscribers as subs +from conftest import connect_service +from druks.core.webhooks import github as github_webhooks +from druks.core.webhooks.github import GitHubEvents +from druks.testing import make_settings + + +def _events(tmp_path, *, payload): + events = GitHubEvents( + request=cast(Any, SimpleNamespace(headers={"x-github-event": "issues"})), + kwargs={}, + settings=make_settings(tmp_path), + ) + events._data_cached = payload + return events + + +def _labeled(*, assignee=None, sender_type="User", sender_login="octocat"): + return { + "action": "labeled", + "label": {"name": "ready-for-agent"}, + "issue": { + "number": 7, + "title": "Contacts import drops the last row", + "html_url": "https://github.com/acme/widget/issues/7", + "assignee": assignee, + }, + "repository": {"full_name": "acme/widget"}, + "sender": {"type": sender_type, "login": sender_login, "id": 583231}, + } + + +def _capture(monkeypatch): + published = [] + + async def _emit(event_type, **kwargs): + published.append((event_type, kwargs)) + + monkeypatch.setattr(github_webhooks, "publish", _emit) + return published + + +async def _connect_github(): + await connect_service( + "github", + identity={"app_id": "1", "slug": "druks-operator"}, + secrets={"private_key": "pem", "webhook_secret": "hook-secret"}, + ) + + +async def test_a_persons_label_publishes_the_label_and_who_put_it(druks_db, tmp_path, monkeypatch): + await _connect_github() + published = _capture(monkeypatch) + + payload = _labeled(assignee={"login": "hubot", "id": 480938}) + await _events(tmp_path, payload=payload).on_issues_labeled() + + assert published == [ + ( + "issue.labeled", + { + "repo": "acme/widget", + "number": 7, + "payload": { + "label": "ready-for-agent", + "actor_id": 583231, + "title": "Contacts import drops the last row", + "url": "https://github.com/acme/widget/issues/7", + "assignee": "hubot", + "assignee_id": 480938, + }, + }, + ) + ] + + +async def test_another_apps_label_publishes_nothing(druks_db, tmp_path, monkeypatch): + await _connect_github() + published = _capture(monkeypatch) + + payload = _labeled(sender_type="Bot", sender_login="renovate[bot]") + await _events(tmp_path, payload=payload).on_issues_labeled() + + assert published == [] + + +async def test_the_apps_own_label_publishes(druks_db, tmp_path, monkeypatch): + # The start route asks for a build by setting the trigger label through the App. + await _connect_github() + published = _capture(monkeypatch) + + payload = _labeled(sender_type="Bot", sender_login="druks-operator[bot]") + await _events(tmp_path, payload=payload).on_issues_labeled() + + assert [event for event, _ in published] == ["issue.labeled"] + + +async def test_the_trigger_label_dispatches_a_build_for_the_issue(monkeypatch): + settings = subs.SoftwareFactory.Settings(tracker="github", trigger_status="ready-for-agent") + + async def _settings(cls): + return settings + + monkeypatch.setattr(subs.SoftwareFactory, "settings", classmethod(_settings)) + build = AsyncMock() + monkeypatch.setattr(subs.Build, "dispatch", build) + + await subs.issue_label_transitions_the_ticket( + repo="acme/widget", + number=7, + payload={ + "label": "ready-for-agent", + "actor_id": 583231, + "title": "Contacts import drops the last row", + "url": "https://github.com/acme/widget/issues/7", + "assignee": None, + "assignee_id": None, + }, + ) + + build.assert_awaited_once_with( + ticket={ + "source": "github", + "identifier": "acme/widget#7", + "status": "ready-for-agent", + "title": "Contacts import drops the last row", + "url": "https://github.com/acme/widget/issues/7", + "project_name": "acme/widget", + "labels": [], + "actor_id": "583231", + "assignee_id": None, + "assignee_email": None, + "assignee_name": None, + } + ) diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index cffafb64e..c7926bb92 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -244,6 +244,18 @@ async def start(cls, **kwargs): assert starts == [] # a repeat call re-stamps; it never dispatches locally +def test_software_factory_start_takes_a_github_key(client: TestClient, monkeypatch): + # owner/repo#12 has a ``/``, which ends a plain path segment, and a ``#``, + # which the caller encodes. + fake = _FakeTracker() + monkeypatch.setattr(SoftwareFactory, "get_tracker", _tracker_stub(fake)) + + response = client.post("/api/software_factory/work-items/acme/widget%2312/start") + + assert response.status_code == 202 + assert fake.calls == [("acme/widget#12", TicketStatus.TRIGGER), "aclose"] + + def test_software_factory_start_translates_an_unknown_tracker_ticket( client: TestClient, monkeypatch ): diff --git a/backend/tests/test_mcp_endpoint.py b/backend/tests/test_mcp_endpoint.py index 2b2759443..2f2103ec1 100644 --- a/backend/tests/test_mcp_endpoint.py +++ b/backend/tests/test_mcp_endpoint.py @@ -227,7 +227,7 @@ async def test_tools_list_pins_platform_and_app_tools(app, pat_token, mode): reason = tools["cancel_run"].input_schema["properties"]["reason"] assert (reason["minLength"], reason["maxLength"]) == (1, 500) assert tools["software_factory_start"].input_schema["properties"]["ticket"]["description"] == ( - "The tracker's ticket key, e.g. ENG-831." + "The tracker's ticket key, e.g. ENG-831, or owner/repo#12 for GitHub." ) # software_factory_start moves the tracker ticket and waits on webhook intake — its # derived description must say so, run-id-free. diff --git a/docs/configuration.md b/docs/configuration.md index 896fda5e9..038af310f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -398,10 +398,10 @@ Webhook URL: Callback URL: `/api/oauth/callback` -Subscribe to issue comment, pull request, pull request review, pull request -review comment, and push events. Keep **Expire user authorization tokens** on: -Druks refreshes a person's sign-in with the refresh token that comes with it, -and refuses a sign-in that has none. +Subscribe to issues, issue comment, pull request, pull request review, pull +request review comment, and push events. Keep **Expire user authorization +tokens** on: Druks refreshes a person's sign-in with the refresh token that +comes with it, and refuses a sign-in that has none. | Repository permission | Access | | --- | --- | @@ -488,16 +488,20 @@ Select the tracker in **Software Factory → Settings**. The default is Linear. and webhook secret. The Jira identity uses a base URL, email, API token, and webhook secret. For an Atlassian service account, the email is its address at `serviceaccount.atlassian.com`. Druks validates the credentials before it -stores them. Linear and Jira share five status settings: trigger, in progress, -in review, done, and resting. When the selected tracker is connected, the -settings page lists its statuses. After you change the tracker, save the -settings to list the statuses of the new tracker. An empty in review or resting -status leaves the ticket where it is. +stores them. Linear, Jira, and GitHub share five status settings: trigger, in +progress, in review, done, and resting. When the selected tracker is connected +and lists its statuses, the settings page offers them as choices. After you +change the tracker, save the settings to list the statuses of the new tracker. +An empty in review or resting status leaves the ticket where it is. Select **druks** to use the ticket board on this appliance. That choice needs no credentials. The status settings stay hidden. The trigger status is Ready for Agent, and it is not a setting. +Select **GitHub** to take work from GitHub Issues in the repositories the +operator GitHub App is already installed on. That choice needs no second +credential. See [GitHub Issues as the tracker](#github-issues-as-the-tracker). + The dashboard shows the board and the ticket pages only for **druks**. Each ticket selects a GitHub repository from a Software Factory project. Druks derives each project's ticket prefix from the project name. It takes the first two @@ -528,19 +532,41 @@ Druks accepts the REST issue JSON under `issue`. Put the shared token in the `x-druks-webhook-token` header. Put `{{initiator.accountId}}` in the `x-jira-initiator` header, so Druks knows who moved the ticket. See [Jira's user smart values](https://support.atlassian.com/cloud-automation/docs/jira-smart-values-users/). -`druks doctor` treats a disconnected Linear or Jira identity as optional when -that tracker is not selected. It reports pending setup if the selected tracker -is Linear or Jira and that identity is missing. - -Software Factory starts a Linear or Jira build under the Druks account of the -person who moved the ticket. If that person has no account, the build uses the -account of the ticket assignee, and then the default account. Linear sends that -person in its webhook. Jira sends that person in the `x-jira-initiator` header. -A person has an account when their tracker ID matches the +`druks doctor` treats a disconnected tracker identity as optional when that +tracker is not selected. It reports pending setup if the selected tracker is +Linear, Jira, or GitHub and its identity is missing. + +Software Factory starts a Linear, Jira, or GitHub build under the Druks account +of the person who moved the ticket. If that person has no account, the build +uses the account of the ticket assignee, and then the default account. Linear +sends that person in its webhook. Jira sends that person in the +`x-jira-initiator` header. GitHub sends the person who added the label. A Linear +or Jira person has an account when their tracker ID matches the [provider account](#oauth-grant-identity) of an MCP connection that they made with **Connect your account**. A connection for everyone has no account, so it -does not match. Two matching accounts also do not match. A **druks** ticket -build uses the account of the ticket assignee. +does not match. A GitHub person has the account that their +[GitHub sign-in](#github) links. Two matching accounts do not match. A **druks** +ticket build uses the account of the ticket assignee. + +### GitHub Issues as the tracker + +GitHub has no status field, so the GitHub tracker reads the five status settings +as **label names**. An issue that gets the trigger label opens a build. The label +arrives through the operator App's webhook, so the App must subscribe to the +**issues** event. An App made with **Create App** subscribes to it. Add the event +to an older App or to an App that you registered by hand. + +The status settings default to Linear status names, so name them for GitHub, for +example `ready-for-agent` and `agent:in-progress`. The settings page lists no +labels, so you type them. GitHub creates a missing label with a default colour +the first time Druks sets it. The labels act as one status: setting one removes +the others, the trigger label too. The done label also closes the issue. + +A label from a person opens a build, and so does a label that Druks sets through +the agent `start` route. A label from another App does nothing. The issue's +repository must be in a Software Factory project, and the build opens its pull +request there. A ticket key is `owner/repo#number`, because issue numbers repeat +across repositories. In a URL path, encode its `#` as `%23`. ## WhatsApp