From 631843f10e325b7a55288c1209d7428f36f394ce Mon Sep 17 00:00:00 2001 From: riddim-developer-bot Date: Mon, 25 May 2026 10:51:23 -0400 Subject: [PATCH] [EPAC-2011]: inherit Linear projectId from originating PR's EPAC issue When the CI failure handler creates a new Linear issue, it now looks up the originating PR via head_sha, extracts the EPAC-XXXX identifier from the PR title, queries Linear for that issue's projectId, and sets it on the new CI-failure issue. A "Triggered by" footer with PR and issue links is appended to the description when both are available. The lookup is best-effort: GitHub or Linear API failures are logged and the issue is created without project inheritance. --- .github/workflows/ci-failure-handler.yml | 2 + scripts/ci/ci_failure_to_linear.py | 148 +++++++++++++- scripts/ci/tests/test_ci_failure_to_linear.py | 193 +++++++++++++++++- 3 files changed, 332 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-failure-handler.yml b/.github/workflows/ci-failure-handler.yml index 8aadb4f1..e8f10dc2 100644 --- a/.github/workflows/ci-failure-handler.yml +++ b/.github/workflows/ci-failure-handler.yml @@ -37,6 +37,7 @@ on: permissions: contents: read + pull-requests: read jobs: create-linear-issue: @@ -63,6 +64,7 @@ jobs: BRANCH_NAME: ${{ github.event.workflow_run.head_branch }} CONCLUSION: ${{ github.event.workflow_run.conclusion }} REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ github.token }} run: | python3 scripts/ci/ci_failure_to_linear.py \ --workflow-name "${WORKFLOW_NAME}" \ diff --git a/scripts/ci/ci_failure_to_linear.py b/scripts/ci/ci_failure_to_linear.py index bf5ab20b..235ecef6 100755 --- a/scripts/ci/ci_failure_to_linear.py +++ b/scripts/ci/ci_failure_to_linear.py @@ -96,6 +96,20 @@ class CIWorkflowOutcome: repo: str +@dataclass(frozen=True) +class PRInfo: + number: int + title: str + html_url: str + + +@dataclass(frozen=True) +class OriginatingContext: + pr: PRInfo + linear_id: str | None = None + project_id: str | None = None + + @dataclass(frozen=True) class LinearIssueRequest: title: str @@ -104,6 +118,7 @@ class LinearIssueRequest: state: str team_key: str label_names: tuple[str, ...] = () + project_id: str | None = None @dataclass(frozen=True) @@ -123,6 +138,53 @@ class TeamResolution: done_state_id: str | None +class GitHubPRClient: + def __init__(self, token: str, *, base_url: str = "https://api.github.com") -> None: + self._token = token + self._base_url = base_url + + def find_pr_for_commit(self, repo: str, sha: str) -> PRInfo | None: + url = f"{self._base_url}/repos/{repo}/commits/{sha}/pulls" + request = Request( + url, + headers={ + "Authorization": f"Bearer {self._token}", + "Accept": "application/vnd.github+json", + }, + ) + try: + with urlopen(request, timeout=30) as response: + pulls = json.loads(response.read().decode("utf-8")) + except (HTTPError, URLError, TimeoutError, OSError) as exc: + logger.warning( + "GitHub PR lookup failed", + extra={"error": str(exc), "sha": sha[:8], "repo": repo}, + ) + return None + + if not pulls: + return None + + for pr in pulls: + if pr.get("merge_commit_sha") == sha: + return PRInfo( + number=pr["number"], + title=pr["title"], + html_url=pr["html_url"], + ) + + return PRInfo( + number=pulls[0]["number"], + title=pulls[0]["title"], + html_url=pulls[0]["html_url"], + ) + + +def extract_linear_id(pr_title: str) -> str | None: + match = re.search(r"\bEPAC-\d+\b", pr_title) + return match.group(0) if match else None + + class LinearAPIError(RuntimeError): def __init__(self, message: str, *, status_code: int | None = None) -> None: super().__init__(message) @@ -148,6 +210,25 @@ def __init__( self._team_cache: dict[str, TeamResolution] = {} self._label_cache: dict[tuple[str, str], str] = {} + def get_issue_project_id(self, identifier: str) -> str | None: + query = """ + query GetIssueProjectId($id: String!) { + issue(id: $id) { + project { + id + } + } + } + """ + data = self._graphql(query, {"id": identifier}) + issue = data.get("issue") + if not issue: + return None + project = issue.get("project") + if not project: + return None + return project.get("id") + def create_issue(self, issue: LinearIssueRequest) -> dict[str, Any]: team = self.resolve_team(issue.team_key) input_payload: dict[str, Any] = { @@ -158,6 +239,8 @@ def create_issue(self, issue: LinearIssueRequest) -> dict[str, Any]: } if team.todo_state_id: input_payload["stateId"] = team.todo_state_id + if issue.project_id: + input_payload["projectId"] = issue.project_id if issue.label_names: input_payload["labelIds"] = [ self.resolve_issue_label_id(issue.team_key, label_name) @@ -386,11 +469,40 @@ def _urlopen_transport(self, payload: dict[str, Any], headers: dict[str, str]) - return exc.code, exc.read().decode("utf-8", errors="replace") +def resolve_originating_context( + outcome: CIWorkflowOutcome, + github_client: GitHubPRClient | None, + linear_client: LinearClient, +) -> OriginatingContext | None: + if not github_client: + return None + + pr = github_client.find_pr_for_commit(outcome.repo, outcome.head_sha) + if not pr: + return None + + linear_id = extract_linear_id(pr.title) + if not linear_id: + return OriginatingContext(pr=pr) + + try: + project_id = linear_client.get_issue_project_id(linear_id) + except LinearAPIError as exc: + logger.warning( + "Linear issue lookup failed for project inheritance", + extra={"linear_id": linear_id, "error": str(exc)}, + ) + project_id = None + + return OriginatingContext(pr=pr, linear_id=linear_id, project_id=project_id) + + def report_ci_workflow_outcome( outcome: CIWorkflowOutcome, linear_client: LinearClient, *, team_key: str, + github_client: GitHubPRClient | None = None, ) -> dict[str, Any] | None: if outcome.conclusion == "success": title = ci_failure_issue_title(outcome) @@ -482,14 +594,17 @@ def report_ci_workflow_outcome( ) return None + originating = resolve_originating_context(outcome, github_client, linear_client) + return linear_client.create_issue( LinearIssueRequest( title=title, - description=initial_ci_failure_description(outcome), + description=initial_ci_failure_description(outcome, originating), priority=1, state="Todo", team_key=team_key, label_names=(CI_FAILURE_LABEL,), + project_id=originating.project_id if originating else None, ) ) @@ -498,15 +613,24 @@ def ci_failure_issue_title(outcome: CIWorkflowOutcome) -> str: return f"[CI] {outcome.workflow_name} failing on {outcome.branch}" -def initial_ci_failure_description(outcome: CIWorkflowOutcome) -> str: - return "\n".join( - [ - "", - "", - f"Failed run: {outcome.run_url}", - f"Head SHA: `{outcome.head_sha[:8]}`", - ] - ) +def initial_ci_failure_description( + outcome: CIWorkflowOutcome, + originating: OriginatingContext | None = None, +) -> str: + lines = [ + "", + "", + f"Failed run: {outcome.run_url}", + f"Head SHA: `{outcome.head_sha[:8]}`", + ] + if originating and originating.linear_id: + lines.append("") + lines.append( + f"Triggered by [PR #{originating.pr.number}]({originating.pr.html_url})" + f" ([{originating.linear_id}]" + f"(linear://linear.app/riddimsoftware/issue/{originating.linear_id}))" + ) + return "\n".join(lines) def ci_failure_comment_body(outcome: CIWorkflowOutcome, attempt_count: int) -> str: @@ -637,11 +761,15 @@ def main(argv: list[str] | None = None) -> int: logger.error("LINEAR_API_TOKEN is required") return 1 + github_token = os.environ.get("GITHUB_TOKEN", "").strip() + github_client = GitHubPRClient(github_token) if github_token else None + try: issue = report_ci_workflow_outcome( outcome, LinearClient(api_token), team_key=args.team_key, + github_client=github_client, ) except (LinearAPIError, ValueError) as exc: logger.error("failed to report workflow outcome", extra={"error": str(exc)}) diff --git a/scripts/ci/tests/test_ci_failure_to_linear.py b/scripts/ci/tests/test_ci_failure_to_linear.py index 4c27cac6..bd6eb239 100644 --- a/scripts/ci/tests/test_ci_failure_to_linear.py +++ b/scripts/ci/tests/test_ci_failure_to_linear.py @@ -24,14 +24,35 @@ def outcome(conclusion: str = "failure") -> ci_failure_to_linear.CIWorkflowOutco ) +class FakeGitHubPRClient: + def __init__(self, pr: ci_failure_to_linear.PRInfo | None = None, error: bool = False) -> None: + self._pr = pr + self._error = error + self.calls: list[tuple[str, str]] = [] + + def find_pr_for_commit(self, repo: str, sha: str) -> ci_failure_to_linear.PRInfo | None: + self.calls.append((repo, sha)) + if self._error: + return None + return self._pr + + class FakeLinearClient: - def __init__(self, existing: list[ci_failure_to_linear.OpenCIFailureIssue] | None = None) -> None: + def __init__( + self, + existing: list[ci_failure_to_linear.OpenCIFailureIssue] | None = None, + project_ids: dict[str, str | None] | None = None, + project_lookup_error: bool = False, + ) -> None: self.existing = existing or [] + self._project_ids = project_ids or {} + self._project_lookup_error = project_lookup_error self.created: list[ci_failure_to_linear.LinearIssueRequest] = [] self.find_requests: list[dict[str, str]] = [] self.comments: list[tuple[str, str]] = [] self.description_updates: list[tuple[str, str]] = [] self.transitions: list[tuple[str, str]] = [] + self.project_id_lookups: list[str] = [] def resolve_team(self, team_key: str) -> ci_failure_to_linear.TeamResolution: return ci_failure_to_linear.TeamResolution( @@ -64,6 +85,12 @@ def add_comment(self, issue_id: str, body: str) -> dict[str, str]: def update_issue_description(self, issue_id: str, description: str) -> None: self.description_updates.append((issue_id, description)) + def get_issue_project_id(self, identifier: str) -> str | None: + self.project_id_lookups.append(identifier) + if self._project_lookup_error: + raise ci_failure_to_linear.LinearAPIError("Linear 500") + return self._project_ids.get(identifier) + def test_no_existing_issue_creates_expected_linear_issue_request() -> None: client = FakeLinearClient() @@ -435,6 +462,170 @@ def fake_transport(payload: dict, _headers: dict[str, str]) -> tuple[int, str]: } +def test_extract_linear_id_matches_epac_prefix() -> None: + assert ci_failure_to_linear.extract_linear_id("[EPAC-1993]: reduce service complexity (#550)") == "EPAC-1993" + + +def test_extract_linear_id_returns_first_match() -> None: + assert ci_failure_to_linear.extract_linear_id("[EPAC-100]: foo EPAC-200") == "EPAC-100" + + +def test_extract_linear_id_no_match() -> None: + assert ci_failure_to_linear.extract_linear_id("some random PR title") is None + + +def test_extract_linear_id_ignores_non_epac_prefix() -> None: + assert ci_failure_to_linear.extract_linear_id("[AUTO-123]: cross-team work") is None + + +def test_extract_linear_id_word_boundary() -> None: + assert ci_failure_to_linear.extract_linear_id("XEPAC-123 foo") is None + + +def test_pr_found_linear_id_matches_project_inherits() -> None: + pr = ci_failure_to_linear.PRInfo( + number=550, + title="[EPAC-1993]: reduce service complexity (#550)", + html_url="https://github.com/RiddimSoftware/epac/pull/550", + ) + github_client = FakeGitHubPRClient(pr=pr) + linear_client = FakeLinearClient(project_ids={"EPAC-1993": "project-abc"}) + + created = ci_failure_to_linear.report_ci_workflow_outcome( + outcome(), + linear_client, # type: ignore[arg-type] + team_key="EPAC", + github_client=github_client, # type: ignore[arg-type] + ) + + assert created is not None + assert len(linear_client.created) == 1 + req = linear_client.created[0] + assert req.project_id == "project-abc" + assert "Triggered by [PR #550]" in req.description + assert "EPAC-1993" in req.description + assert "linear://linear.app/riddimsoftware/issue/EPAC-1993" in req.description + + +def test_pr_found_linear_id_matches_issue_unparented() -> None: + pr = ci_failure_to_linear.PRInfo( + number=550, + title="[EPAC-1993]: reduce service complexity", + html_url="https://github.com/RiddimSoftware/epac/pull/550", + ) + github_client = FakeGitHubPRClient(pr=pr) + linear_client = FakeLinearClient(project_ids={"EPAC-1993": None}) + + created = ci_failure_to_linear.report_ci_workflow_outcome( + outcome(), + linear_client, # type: ignore[arg-type] + team_key="EPAC", + github_client=github_client, # type: ignore[arg-type] + ) + + assert created is not None + req = linear_client.created[0] + assert req.project_id is None + assert "Triggered by [PR #550]" in req.description + assert "EPAC-1993" in req.description + + +def test_pr_found_no_linear_id_match_no_footer() -> None: + pr = ci_failure_to_linear.PRInfo( + number=600, + title="chore: update deps", + html_url="https://github.com/RiddimSoftware/epac/pull/600", + ) + github_client = FakeGitHubPRClient(pr=pr) + linear_client = FakeLinearClient() + + created = ci_failure_to_linear.report_ci_workflow_outcome( + outcome(), + linear_client, # type: ignore[arg-type] + team_key="EPAC", + github_client=github_client, # type: ignore[arg-type] + ) + + assert created is not None + req = linear_client.created[0] + assert req.project_id is None + assert "Triggered by" not in req.description + assert linear_client.project_id_lookups == [] + + +def test_no_pr_for_sha_no_footer() -> None: + github_client = FakeGitHubPRClient(pr=None) + linear_client = FakeLinearClient() + + created = ci_failure_to_linear.report_ci_workflow_outcome( + outcome(), + linear_client, # type: ignore[arg-type] + team_key="EPAC", + github_client=github_client, # type: ignore[arg-type] + ) + + assert created is not None + req = linear_client.created[0] + assert req.project_id is None + assert "Triggered by" not in req.description + + +def test_github_api_failure_graceful_noop() -> None: + github_client = FakeGitHubPRClient(error=True) + linear_client = FakeLinearClient() + + created = ci_failure_to_linear.report_ci_workflow_outcome( + outcome(), + linear_client, # type: ignore[arg-type] + team_key="EPAC", + github_client=github_client, # type: ignore[arg-type] + ) + + assert created is not None + req = linear_client.created[0] + assert req.project_id is None + assert "Triggered by" not in req.description + + +def test_linear_project_lookup_failure_graceful_noop() -> None: + pr = ci_failure_to_linear.PRInfo( + number=550, + title="[EPAC-1993]: reduce service complexity", + html_url="https://github.com/RiddimSoftware/epac/pull/550", + ) + github_client = FakeGitHubPRClient(pr=pr) + linear_client = FakeLinearClient(project_lookup_error=True) + + created = ci_failure_to_linear.report_ci_workflow_outcome( + outcome(), + linear_client, # type: ignore[arg-type] + team_key="EPAC", + github_client=github_client, # type: ignore[arg-type] + ) + + assert created is not None + req = linear_client.created[0] + assert req.project_id is None + assert "Triggered by [PR #550]" in req.description + assert "EPAC-1993" in req.description + + +def test_no_github_client_creates_issue_without_project() -> None: + linear_client = FakeLinearClient() + + created = ci_failure_to_linear.report_ci_workflow_outcome( + outcome(), + linear_client, # type: ignore[arg-type] + team_key="EPAC", + github_client=None, + ) + + assert created is not None + req = linear_client.created[0] + assert req.project_id is None + assert "Triggered by" not in req.description + + def test_find_open_issue_uses_exact_linear_graphql_filter() -> None: requests: list[dict] = []