Skip to content

Commit b5826ee

Browse files
✨ [+feature] Added GitHub CI/CD (#22)
2 parents 5131c63 + 86c8dc4 commit b5826ee

6 files changed

Lines changed: 1097 additions & 59 deletions

File tree

TODO.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
## Archived Status - Whether the repo is archived
2+
3+
Already available (archived field)
4+
Useful visual indicator (maybe gray out row)

src/AllGitStatus/MainApp.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class Column:
4444
IssuesColumn = Column(8, "Issues", "center")
4545
PullRequestsColumn = Column(9, "PRs", "center")
4646
SecurityAlertsColumn = Column(10, "Security", "center")
47+
CICDStatusColumn = Column(11, "CI/CD", "center")
4748

4849
COLUMN_MAP: dict[
4950
tuple[
@@ -63,6 +64,7 @@ class Column:
6364
(GitHubSource.__name__, "issues"): IssuesColumn,
6465
(GitHubSource.__name__, "pull_requests"): PullRequestsColumn,
6566
(GitHubSource.__name__, "security_alerts"): SecurityAlertsColumn,
67+
(GitHubSource.__name__, "cicd_status"): CICDStatusColumn,
6668
}
6769

6870

@@ -329,7 +331,7 @@ def _PopulateCell(self, repository_index: int, info: ResultInfo | ErrorInfo) ->
329331
column = COLUMN_MAP[info.key]
330332

331333
if isinstance(info, ErrorInfo):
332-
display_value = ""
334+
display_value = "💥"
333335
additional_info = Traceback.from_exception(
334336
type(info.error),
335337
info.error,

src/AllGitStatus/Sources/GitHubSource.py

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# noqa: D100
22
import re
3+
import textwrap
4+
35
from collections.abc import AsyncGenerator
46

57
import aiohttp
@@ -45,7 +47,9 @@ async def Query(self, repo: Repository) -> AsyncGenerator[ResultInfo | ErrorInfo
4547

4648
github_url = repo.remote_url.removesuffix(".git")
4749

48-
async for info in self._GenerateStandardInfo(repo, github_url):
50+
persisted_info: dict[str, object] = {}
51+
52+
async for info in self._GenerateStandardInfo(repo, github_url, persisted_info):
4953
yield info
5054

5155
async for info in self._GenerateIssueInfo(repo, github_url):
@@ -57,13 +61,21 @@ async def Query(self, repo: Repository) -> AsyncGenerator[ResultInfo | ErrorInfo
5761
async for info in self._GenerateSecurityAlertInfo(repo, github_url):
5862
yield info
5963

64+
default_branch = persisted_info.get("default_branch")
65+
66+
# Only generate CI/CD info if we have a default branch (repo API succeeded)
67+
if isinstance(default_branch, str):
68+
async for info in self._GenerateCICDInfo(repo, github_url, default_branch):
69+
yield info
70+
6071
# ----------------------------------------------------------------------
6172
# ----------------------------------------------------------------------
6273
# ----------------------------------------------------------------------
6374
async def _GenerateStandardInfo(
6475
self,
6576
repo: Repository,
6677
github_url: str,
78+
persisted_info: dict[str, object],
6779
) -> AsyncGenerator[ResultInfo | ErrorInfo]:
6880
try:
6981
async with self._session.get(
@@ -72,6 +84,9 @@ async def _GenerateStandardInfo(
7284
response.raise_for_status()
7385
result = await response.json()
7486

87+
# Capture default_branch for use by CI/CD status
88+
persisted_info["default_branch"] = result.get("default_branch")
89+
7590
yield ResultInfo(
7691
repo,
7792
(self.__class__.__name__, "stars"),
@@ -135,7 +150,7 @@ async def _GenerateIssueInfo(
135150

136151
label_str = f" [{', '.join(issue_labels)}]" if issue_labels else ""
137152

138-
issue_data.append(f" • #{issue_number}{label_str} {issue_title} (by {issue_author})")
153+
issue_data.append(f"• #{issue_number}{label_str} {issue_title} (by {issue_author})")
139154

140155
# Build additional info with issue details
141156
additional_info_lines = [
@@ -191,7 +206,7 @@ async def _GeneratePullRequestInfo(
191206

192207
draft_indicator = "[DRAFT] " if pr_draft else ""
193208

194-
pr_data.append(f" • #{pr_number} {draft_indicator}{pr_title} (by {pr_author})")
209+
pr_data.append(f"• #{pr_number} {draft_indicator}{pr_title} (by {pr_author})")
195210

196211
additional_info_lines = [
197212
f"Pull Requests: {github_url}/pulls",
@@ -246,7 +261,7 @@ async def _GenerateSecurityAlertInfo(
246261
package = alert.get("security_vulnerability", {}).get("package", {})
247262

248263
alert_data.append(
249-
f" • [{advisory.get('severity', 'unknown').upper()}] {package.get('name', 'unknown')}: {advisory.get('summary', 'No summary')}"
264+
f"• [{advisory.get('severity', 'unknown').upper()}] {package.get('name', 'unknown')}: {advisory.get('summary', 'No summary')}"
250265
)
251266

252267
# Build display value with icon based on severity
@@ -290,6 +305,118 @@ async def _GenerateSecurityAlertInfo(
290305
ex,
291306
)
292307

308+
# ----------------------------------------------------------------------
309+
async def _GenerateCICDInfo(
310+
self,
311+
repo: Repository,
312+
github_url: str,
313+
default_branch: str,
314+
) -> AsyncGenerator[ResultInfo | ErrorInfo]:
315+
info_key = (self.__class__.__name__, "cicd_status")
316+
317+
try:
318+
url = f"https://api.github.com/repos/{repo.github_owner}/{repo.github_repo}/actions/runs?branch={default_branch}&per_page=100"
319+
320+
async with self._session.get(url) as response:
321+
response.raise_for_status()
322+
result = await response.json()
323+
324+
workflow_runs = result.get("workflow_runs", [])
325+
326+
if not workflow_runs:
327+
yield ResultInfo(
328+
repo,
329+
info_key,
330+
"🔘",
331+
textwrap.dedent(
332+
"""\
333+
CI/CD Status: {github_url}/actions
334+
335+
No workflow runs found for branch '{default_branch}'
336+
""",
337+
).format(
338+
github_url=github_url,
339+
default_branch=default_branch,
340+
),
341+
)
342+
return
343+
344+
# Group by workflow name and take only the most recent run for each
345+
latest_per_workflow: dict[str, dict] = {}
346+
347+
for run in workflow_runs:
348+
workflow_id = run["workflow_id"]
349+
350+
if workflow_id not in latest_per_workflow:
351+
latest_per_workflow[workflow_id] = run
352+
353+
# Count statuses based on most recent run per workflow
354+
status_counts = {
355+
"success": 0,
356+
"failure": 0,
357+
"in_progress": 0,
358+
}
359+
360+
run_details: list[str] = []
361+
362+
for run in latest_per_workflow.values():
363+
conclusion = run.get("conclusion")
364+
status = run.get("status")
365+
366+
# Determine the effective status
367+
if status in ("in_progress", "queued", "pending", "waiting"):
368+
status_counts["in_progress"] += 1
369+
status_label = "IN PROG"
370+
elif conclusion == "success":
371+
status_counts["success"] += 1
372+
status_label = " PASS "
373+
elif conclusion in ("failure", "cancelled", "timed_out"):
374+
status_counts["failure"] += 1
375+
status_label = " FAIL "
376+
else:
377+
status_label = conclusion or status or "UNKNOWN"
378+
379+
run_details.append(f"• [{status_label}] {run['created_at']} {run['path']}: {run['name']}")
380+
381+
# Determine display icon based on priority: failure > in_progress > success
382+
if status_counts["failure"] > 0:
383+
display_icon = "❌"
384+
elif status_counts["in_progress"] > 0:
385+
display_icon = "⏳"
386+
elif status_counts["success"] > 0:
387+
display_icon = "✅"
388+
else:
389+
display_icon = "🔘"
390+
391+
# Build additional info
392+
additional_info_lines = [
393+
f"CI/CD Status: {github_url}/actions",
394+
"",
395+
f"Default Branch: {default_branch}",
396+
"",
397+
"Summary (latest run per workflow):",
398+
f" Successful: {status_counts['success']}",
399+
f" Failed: {status_counts['failure']}",
400+
f" In Progress: {status_counts['in_progress']}",
401+
"",
402+
]
403+
404+
additional_info_lines.extend(run_details)
405+
406+
yield ResultInfo(
407+
repo,
408+
info_key,
409+
display_icon,
410+
"\n".join(additional_info_lines),
411+
)
412+
413+
except Exception as ex:
414+
yield ErrorInfo(
415+
repo,
416+
info_key,
417+
ex,
418+
)
419+
293420
# ----------------------------------------------------------------------
294421
async def _GeneratePaginatedResults(self, raw_url: str) -> AsyncGenerator[dict]:
295422
url: str | None = f"{raw_url}?state=open&per_page=100"

tests/MainApp_integration_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from AllGitStatus.MainApp import (
1818
COLUMN_MAP,
1919
BranchColumn,
20+
CICDStatusColumn,
2021
Column,
2122
ForksColumn,
2223
IssuesColumn,
@@ -655,7 +656,7 @@ async def mock_enum(wd):
655656

656657
# Verify the cell was updated with error indicator
657658
cell_value = app._data_table.get_cell_at(Coordinate(0, BranchColumn.value))
658-
assert "" in str(cell_value)
659+
assert "💥" in str(cell_value)
659660

660661
# The additional_info_data should contain a Traceback
661662
assert 0 in app._additional_info_data
@@ -958,6 +959,7 @@ def test_column_map_contains_all_columns(self) -> None:
958959
IssuesColumn,
959960
PullRequestsColumn,
960961
SecurityAlertsColumn,
962+
CICDStatusColumn,
961963
}
962964

963965
assert unique_columns == expected_columns

tests/Sources/GitHubSource_integration_test.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ async def test_query_returns_valid_results(
194194

195195
# Verify all display values contain numeric counts (for ResultInfo items)
196196
for result in results:
197-
if isinstance(result, ResultInfo):
197+
if isinstance(result, ResultInfo) and result.key[1] != "cicd_status":
198198
number_part = result.display_value.split()[0]
199199
assert number_part.isdigit(), f"Expected number in '{result.display_value}'"
200200

@@ -221,13 +221,15 @@ async def test_query_nonexistent_repo_returns_error(self, session: aiohttp.Clien
221221

222222
results = [info async for info in source.Query(repo)]
223223

224-
# All four API calls fail for nonexistent repos (stars, issues, pull_requests, security_alerts)
224+
# Four API calls fail for nonexistent repos (stars, issues, pull_requests, security_alerts)
225+
# CI/CD is skipped because we don't have default_branch when repo API fails
225226
assert len(results) == 4
226-
assert all(isinstance(r, ErrorInfo) for r in results)
227227
assert results[0].key == ("GitHubSource", "stars")
228228
assert results[1].key == ("GitHubSource", "issues")
229229
assert results[2].key == ("GitHubSource", "pull_requests")
230230
assert results[3].key == ("GitHubSource", "security_alerts")
231+
# All results are errors
232+
assert all(isinstance(r, ErrorInfo) for r in results)
231233

232234

233235
# ----------------------------------------------------------------------

0 commit comments

Comments
 (0)