Skip to content

Commit bb4bbd9

Browse files
riddim-developer-bot[bot]riddim-developer-bot
andauthored
[EPAC-2011]: inherit Linear projectId from originating PR's EPAC issue (#586)
## Why CI-failure issues created by the handler landed unparented in Linear. When a workflow fails after a PR merge, the failure issue should inherit the originating PR's EPAC issue's Linear project so it appears in the same project view as the work that caused it. ## What changed - **`GitHubPRClient`**: New thin client with `find_pr_for_commit(repo, sha)` that calls `GET /repos/{owner}/{repo}/commits/{sha}/pulls`, preferring the PR whose `merge_commit_sha` matches the head SHA and falling back to the first PR. - **`PRInfo` / `OriginatingContext`**: Value objects carrying the originating PR metadata and optional Linear project inheritance. - **`extract_linear_id(pr_title)`**: Regex `\bEPAC-\d+\b` extracts the first EPAC identifier from a PR title. Non-EPAC prefixes are ignored. - **`LinearClient.get_issue_project_id(identifier)`**: GraphQL query returning the originating issue's `projectId` (or `None` if unparented/missing). - **`resolve_originating_context`**: Orchestrates the lookup chain (GitHub PR → regex → Linear project). Best-effort: any failure is logged and falls through to unparented creation. - **`report_ci_workflow_outcome`**: Accepts optional `github_client`; on the create path, resolves originating context, sets `projectId` on the new issue, and appends a "Triggered by" footer linking back to the PR and originating EPAC issue. - **`LinearIssueRequest.project_id`**: New optional field; `create_issue` includes `projectId` in the mutation payload when set. - **Workflow YAML**: Added `pull-requests: read` permission and `GITHUB_TOKEN` env var. ## Trade-offs not taken - Separate `scripts/ci/github_pr_client.py` module: kept inline with the existing single-file pattern since the client is thin (one method) and the script has no other modules. - Caching across handler runs: unnecessary since each `workflow_run` event is a single invocation. - Cross-team ID parsing (e.g. `AUTO-XXX`): out of scope per the issue. ## Test plan Unit tests cover all 6 AC scenarios plus `extract_linear_id` edge cases (22 tests, all passing): - (a) PR found + Linear ID matches + issue has projectId → inherits projectId, footer present - (b) PR found + Linear ID matches + issue unparented → no projectId, footer present - (c) PR found + no Linear ID regex match → no projectId, no footer - (d) No PR for SHA (direct push) → no projectId, no footer - (e) GitHub API failure → graceful no-op, issue still created - (f) Linear project lookup failure → graceful no-op, issue still created with footer ``` $ python3 -m pytest scripts/ci/tests/test_ci_failure_to_linear.py -v 22 passed in 0.05s ``` ## Verification evidence - All 22 unit tests pass - Existing EPAC-2009/2010 dedup and auto-close behavior unchanged (dedup path is untouched) - Workflow actionlint passes (pre-commit hook validated) Resolves EPAC-2011 Reviewer-Boundary: review-only Co-authored-by: riddim-developer-bot <developer-bot@riddimsoftware.com>
1 parent e5ea10f commit bb4bbd9

3 files changed

Lines changed: 332 additions & 11 deletions

File tree

.github/workflows/ci-failure-handler.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ on:
3737

3838
permissions:
3939
contents: read
40+
pull-requests: read
4041

4142
jobs:
4243
create-linear-issue:
@@ -63,6 +64,7 @@ jobs:
6364
BRANCH_NAME: ${{ github.event.workflow_run.head_branch }}
6465
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
6566
REPOSITORY: ${{ github.repository }}
67+
GITHUB_TOKEN: ${{ github.token }}
6668
run: |
6769
python3 scripts/ci/ci_failure_to_linear.py \
6870
--workflow-name "${WORKFLOW_NAME}" \

scripts/ci/ci_failure_to_linear.py

Lines changed: 138 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,20 @@ class CIWorkflowOutcome:
9696
repo: str
9797

9898

99+
@dataclass(frozen=True)
100+
class PRInfo:
101+
number: int
102+
title: str
103+
html_url: str
104+
105+
106+
@dataclass(frozen=True)
107+
class OriginatingContext:
108+
pr: PRInfo
109+
linear_id: str | None = None
110+
project_id: str | None = None
111+
112+
99113
@dataclass(frozen=True)
100114
class LinearIssueRequest:
101115
title: str
@@ -104,6 +118,7 @@ class LinearIssueRequest:
104118
state: str
105119
team_key: str
106120
label_names: tuple[str, ...] = ()
121+
project_id: str | None = None
107122

108123

109124
@dataclass(frozen=True)
@@ -123,6 +138,53 @@ class TeamResolution:
123138
done_state_id: str | None
124139

125140

141+
class GitHubPRClient:
142+
def __init__(self, token: str, *, base_url: str = "https://api.github.com") -> None:
143+
self._token = token
144+
self._base_url = base_url
145+
146+
def find_pr_for_commit(self, repo: str, sha: str) -> PRInfo | None:
147+
url = f"{self._base_url}/repos/{repo}/commits/{sha}/pulls"
148+
request = Request(
149+
url,
150+
headers={
151+
"Authorization": f"Bearer {self._token}",
152+
"Accept": "application/vnd.github+json",
153+
},
154+
)
155+
try:
156+
with urlopen(request, timeout=30) as response:
157+
pulls = json.loads(response.read().decode("utf-8"))
158+
except (HTTPError, URLError, TimeoutError, OSError) as exc:
159+
logger.warning(
160+
"GitHub PR lookup failed",
161+
extra={"error": str(exc), "sha": sha[:8], "repo": repo},
162+
)
163+
return None
164+
165+
if not pulls:
166+
return None
167+
168+
for pr in pulls:
169+
if pr.get("merge_commit_sha") == sha:
170+
return PRInfo(
171+
number=pr["number"],
172+
title=pr["title"],
173+
html_url=pr["html_url"],
174+
)
175+
176+
return PRInfo(
177+
number=pulls[0]["number"],
178+
title=pulls[0]["title"],
179+
html_url=pulls[0]["html_url"],
180+
)
181+
182+
183+
def extract_linear_id(pr_title: str) -> str | None:
184+
match = re.search(r"\bEPAC-\d+\b", pr_title)
185+
return match.group(0) if match else None
186+
187+
126188
class LinearAPIError(RuntimeError):
127189
def __init__(self, message: str, *, status_code: int | None = None) -> None:
128190
super().__init__(message)
@@ -148,6 +210,25 @@ def __init__(
148210
self._team_cache: dict[str, TeamResolution] = {}
149211
self._label_cache: dict[tuple[str, str], str] = {}
150212

213+
def get_issue_project_id(self, identifier: str) -> str | None:
214+
query = """
215+
query GetIssueProjectId($id: String!) {
216+
issue(id: $id) {
217+
project {
218+
id
219+
}
220+
}
221+
}
222+
"""
223+
data = self._graphql(query, {"id": identifier})
224+
issue = data.get("issue")
225+
if not issue:
226+
return None
227+
project = issue.get("project")
228+
if not project:
229+
return None
230+
return project.get("id")
231+
151232
def create_issue(self, issue: LinearIssueRequest) -> dict[str, Any]:
152233
team = self.resolve_team(issue.team_key)
153234
input_payload: dict[str, Any] = {
@@ -158,6 +239,8 @@ def create_issue(self, issue: LinearIssueRequest) -> dict[str, Any]:
158239
}
159240
if team.todo_state_id:
160241
input_payload["stateId"] = team.todo_state_id
242+
if issue.project_id:
243+
input_payload["projectId"] = issue.project_id
161244
if issue.label_names:
162245
input_payload["labelIds"] = [
163246
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]) -
386469
return exc.code, exc.read().decode("utf-8", errors="replace")
387470

388471

472+
def resolve_originating_context(
473+
outcome: CIWorkflowOutcome,
474+
github_client: GitHubPRClient | None,
475+
linear_client: LinearClient,
476+
) -> OriginatingContext | None:
477+
if not github_client:
478+
return None
479+
480+
pr = github_client.find_pr_for_commit(outcome.repo, outcome.head_sha)
481+
if not pr:
482+
return None
483+
484+
linear_id = extract_linear_id(pr.title)
485+
if not linear_id:
486+
return OriginatingContext(pr=pr)
487+
488+
try:
489+
project_id = linear_client.get_issue_project_id(linear_id)
490+
except LinearAPIError as exc:
491+
logger.warning(
492+
"Linear issue lookup failed for project inheritance",
493+
extra={"linear_id": linear_id, "error": str(exc)},
494+
)
495+
project_id = None
496+
497+
return OriginatingContext(pr=pr, linear_id=linear_id, project_id=project_id)
498+
499+
389500
def report_ci_workflow_outcome(
390501
outcome: CIWorkflowOutcome,
391502
linear_client: LinearClient,
392503
*,
393504
team_key: str,
505+
github_client: GitHubPRClient | None = None,
394506
) -> dict[str, Any] | None:
395507
if outcome.conclusion == "success":
396508
title = ci_failure_issue_title(outcome)
@@ -482,14 +594,17 @@ def report_ci_workflow_outcome(
482594
)
483595
return None
484596

597+
originating = resolve_originating_context(outcome, github_client, linear_client)
598+
485599
return linear_client.create_issue(
486600
LinearIssueRequest(
487601
title=title,
488-
description=initial_ci_failure_description(outcome),
602+
description=initial_ci_failure_description(outcome, originating),
489603
priority=1,
490604
state="Todo",
491605
team_key=team_key,
492606
label_names=(CI_FAILURE_LABEL,),
607+
project_id=originating.project_id if originating else None,
493608
)
494609
)
495610

@@ -498,15 +613,24 @@ def ci_failure_issue_title(outcome: CIWorkflowOutcome) -> str:
498613
return f"[CI] {outcome.workflow_name} failing on {outcome.branch}"
499614

500615

501-
def initial_ci_failure_description(outcome: CIWorkflowOutcome) -> str:
502-
return "\n".join(
503-
[
504-
"<!-- ci-failure-handler:comment-count:0 -->",
505-
"",
506-
f"Failed run: {outcome.run_url}",
507-
f"Head SHA: `{outcome.head_sha[:8]}`",
508-
]
509-
)
616+
def initial_ci_failure_description(
617+
outcome: CIWorkflowOutcome,
618+
originating: OriginatingContext | None = None,
619+
) -> str:
620+
lines = [
621+
"<!-- ci-failure-handler:comment-count:0 -->",
622+
"",
623+
f"Failed run: {outcome.run_url}",
624+
f"Head SHA: `{outcome.head_sha[:8]}`",
625+
]
626+
if originating and originating.linear_id:
627+
lines.append("")
628+
lines.append(
629+
f"Triggered by [PR #{originating.pr.number}]({originating.pr.html_url})"
630+
f" ([{originating.linear_id}]"
631+
f"(linear://linear.app/riddimsoftware/issue/{originating.linear_id}))"
632+
)
633+
return "\n".join(lines)
510634

511635

512636
def ci_failure_comment_body(outcome: CIWorkflowOutcome, attempt_count: int) -> str:
@@ -637,11 +761,15 @@ def main(argv: list[str] | None = None) -> int:
637761
logger.error("LINEAR_API_TOKEN is required")
638762
return 1
639763

764+
github_token = os.environ.get("GITHUB_TOKEN", "").strip()
765+
github_client = GitHubPRClient(github_token) if github_token else None
766+
640767
try:
641768
issue = report_ci_workflow_outcome(
642769
outcome,
643770
LinearClient(api_token),
644771
team_key=args.team_key,
772+
github_client=github_client,
645773
)
646774
except (LinearAPIError, ValueError) as exc:
647775
logger.error("failed to report workflow outcome", extra={"error": str(exc)})

0 commit comments

Comments
 (0)