Skip to content

Commit c2d0dfd

Browse files
aorumbayevclaude
andcommitted
fix(integrations): require repo_id for GitHub issue import
GitHub issue import now requires tasks to be linked to a repository. Previously, if no repo was selected or attached, tasks were created with repo_id=null, making them invisible in the TUI when filtered by repository. Changes: - _resolve_task_repo_id now raises KaganError instead of returning None - Clear error messages for: no repos attached, multiple repos unselected - Updated test fixtures to attach repos for import tests - Added tests for the new error conditions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent eda9fda commit c2d0dfd

4 files changed

Lines changed: 154 additions & 67 deletions

File tree

src/kagan/core/integrations/github.py

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -130,16 +130,12 @@ def normalize_github_state(state: str) -> str:
130130
def canonical_repo_slug(repo_slug: str) -> str:
131131
value = repo_slug.strip()
132132
if "/" not in value:
133-
raise ValueError(
134-
"Repository must use owner/repo format (for example octocat/hello-world)"
135-
)
133+
raise ValueError("Repository must use owner/repo format (for example octocat/hello-world)")
136134
owner, name = value.split("/", 1)
137135
owner = owner.strip()
138136
name = name.strip()
139137
if not owner or not name or " " in owner or " " in name:
140-
raise ValueError(
141-
"Repository must use owner/repo format (for example octocat/hello-world)"
142-
)
138+
raise ValueError("Repository must use owner/repo format (for example octocat/hello-world)")
143139
return f"{owner}/{name}"
144140

145141

@@ -310,9 +306,7 @@ async def _gh_list_comments(repo_slug: str, number: int) -> list[dict[str, Any]]
310306
311307
Returns a list of comment dicts (id, body, user, created_at, updated_at).
312308
"""
313-
resolved = resolve_spawn_command(
314-
"gh", "api", f"repos/{repo_slug}/issues/{number}/comments"
315-
)
309+
resolved = resolve_spawn_command("gh", "api", f"repos/{repo_slug}/issues/{number}/comments")
316310
proc = await asyncio.create_subprocess_exec(
317311
*resolved,
318312
env=build_sanitized_subprocess_environment(),
@@ -589,9 +583,7 @@ def _render_criteria_comment(criteria: list[Any]) -> str:
589583
try:
590584
verdicts = list(getattr(crit, "verdicts", []) or [])
591585
if verdicts:
592-
latest = sorted(
593-
verdicts, key=lambda v: getattr(v, "created_at", ""), reverse=True
594-
)
586+
latest = sorted(verdicts, key=lambda v: getattr(v, "created_at", ""), reverse=True)
595587
top_verdict = getattr(latest[0], "verdict", "").upper()
596588
is_done = top_verdict == "PASS"
597589
except Exception:
@@ -641,8 +633,11 @@ async def _sync_criteria_via_comment(
641633
new_body = _render_criteria_comment(criteria)
642634
comments = await _gh_list_comments(repo_slug, number)
643635
tagged = next(
644-
(c for c in comments if isinstance(c.get("body"), str) and
645-
c["body"].startswith(_KAGAN_CRITERIA_MARKER)),
636+
(
637+
c
638+
for c in comments
639+
if isinstance(c.get("body"), str) and c["body"].startswith(_KAGAN_CRITERIA_MARKER)
640+
),
646641
None,
647642
)
648643
if tagged is not None:
@@ -652,14 +647,10 @@ async def _sync_criteria_via_comment(
652647
else:
653648
await _gh_create_comment(repo_slug, number, new_body)
654649
except Exception as exc:
655-
logger.warning(
656-
"Failed to sync criteria comment for {}#{}: {}", repo_slug, number, exc
657-
)
650+
logger.warning("Failed to sync criteria comment for {}#{}: {}", repo_slug, number, exc)
658651

659652

660-
async def _pull_criteria_from_comment(
661-
repo_slug: str, number: int
662-
) -> list[tuple[str, bool]] | None:
653+
async def _pull_criteria_from_comment(repo_slug: str, number: int) -> list[tuple[str, bool]] | None:
663654
"""Find the tagged criteria comment and parse its lines.
664655
665656
Returns ``[(text, done)]`` if a tagged comment exists, else ``None``.
@@ -669,8 +660,11 @@ async def _pull_criteria_from_comment(
669660
except KaganError:
670661
return None
671662
tagged = next(
672-
(c for c in comments if isinstance(c.get("body"), str) and
673-
c["body"].startswith(_KAGAN_CRITERIA_MARKER)),
663+
(
664+
c
665+
for c in comments
666+
if isinstance(c.get("body"), str) and c["body"].startswith(_KAGAN_CRITERIA_MARKER)
667+
),
674668
None,
675669
)
676670
if tagged is None:
@@ -723,18 +717,28 @@ async def _resolve_task_repo_id(
723717
client: KaganCore,
724718
project_id: str,
725719
configured_repo_id: str | None,
726-
) -> str | None:
727-
"""Pick the repo_id that imported tasks should belong to, when available."""
720+
) -> str:
721+
"""Pick the repo_id that imported tasks should belong to.
722+
723+
Raises:
724+
KaganError: If no repo_id can be determined (no repos attached to
725+
the project, or multiple repos with none selected).
726+
"""
728727
if configured_repo_id:
729728
return configured_repo_id
730729

731730
try:
732731
repos = await client.projects.repos(project_id)
733-
except KaganError:
734-
return None
732+
except KaganError as exc:
733+
raise KaganError(
734+
"Failed to resolve repository for import. Attach a repository to the project first."
735+
) from exc
735736

736737
if not repos:
737-
return None
738+
raise KaganError(
739+
"No repositories attached to this project. "
740+
"Attach a repository before importing GitHub issues."
741+
)
738742

739743
settings = await client.settings.get()
740744
selected_repo_id = settings.get(f"ui.selected_repo.{project_id}")
@@ -744,7 +748,11 @@ async def _resolve_task_repo_id(
744748
if len(repos) == 1:
745749
return repos[0].id
746750

747-
return None
751+
repo_paths = ", ".join(r.path for r in repos)
752+
raise KaganError(
753+
f"Multiple repositories attached to this project ({repo_paths}). "
754+
"Select a repository in the TUI/web UI before importing, or pass target_repo_id explicitly."
755+
)
748756

749757

750758
# ---------------------------------------------------------------------------
@@ -764,8 +772,8 @@ class GitHubIntegration:
764772
from kagan.core.integrations import github
765773
766774
result = await github.sync(client, config, project_id)
767-
items = await github.preview(client, config, project_id)
768-
checks = github.preflight()
775+
items = await github.preview(client, config, project_id)
776+
checks = github.preflight()
769777
"""
770778

771779
id: str = "github"
@@ -836,9 +844,7 @@ def preflight(self) -> list[PreflightCheckResult]:
836844

837845
# -- Internal helpers ---------------------------------------------------
838846

839-
async def _fetch_issues(
840-
self, config: GitHubConfig
841-
) -> list[GitHubIssue]:
847+
async def _fetch_issues(self, config: GitHubConfig) -> list[GitHubIssue]:
842848
"""Validate prerequisites and fetch issues from GitHub."""
843849
if _gh_path() is None:
844850
raise KaganError("GitHub CLI (gh) not found. Install → https://cli.github.com")
@@ -954,9 +960,7 @@ async def _refresh_existing_task(
954960
update_kwargs["acceptance_criteria"] = [text for text, _done in pulled]
955961

956962
if not update_kwargs:
957-
logger.debug(
958-
"GitHub pull: issue #{} unchanged, skipping task {}", number, task_id
959-
)
963+
logger.debug("GitHub pull: issue #{} unchanged, skipping task {}", number, task_id)
960964
return ImportResult(
961965
created=result.created,
962966
updated=result.updated,
@@ -1066,7 +1070,8 @@ async def push_task_change(
10661070
current_labels = await _gh_issue_labels(slug, number)
10671071
add_labels = [new_label] if new_label not in current_labels else []
10681072
remove_labels = [
1069-
lbl for lbl in current_labels
1073+
lbl
1074+
for lbl in current_labels
10701075
if lbl in _ALL_PRIORITY_LABELS and lbl != new_label
10711076
]
10721077
await _gh_ensure_label(slug, new_label)

tests/integrations/test_github.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ async def client(tmp_path):
3434
c = KaganCore(db_path=tmp_path / "test.db")
3535
project = await c.projects.create("Test Project")
3636
await c.projects.set_active(project.id)
37+
# Attach a repo so GitHub import has a target repo_id
38+
repo_path = tmp_path / "test-repo"
39+
await make_git_repo(repo_path)
40+
await c.projects.add_repo(project.id, str(repo_path))
3741
yield c
3842
c.close()
3943

@@ -403,7 +407,11 @@ async def test_sync_assigns_imported_tasks_to_selected_repo(
403407
assert tasks[0].title == "Repo-scoped bug"
404408

405409

406-
@patch("kagan.core.integrations.github._pull_criteria_from_comment", new_callable=AsyncMock, return_value=None)
410+
@patch(
411+
"kagan.core.integrations.github._pull_criteria_from_comment",
412+
new_callable=AsyncMock,
413+
return_value=None,
414+
)
407415
@patch("kagan.core.integrations.github._gh_view_issue", new_callable=AsyncMock)
408416
@patch("kagan.core.integrations.github._gh_fetch_issues", new_callable=AsyncMock)
409417
@patch(
@@ -458,7 +466,11 @@ async def test_sync_reimports_deleted_tasks(
458466
assert result2.skipped == 0
459467

460468

461-
@patch("kagan.core.integrations.github._pull_criteria_from_comment", new_callable=AsyncMock, return_value=None)
469+
@patch(
470+
"kagan.core.integrations.github._pull_criteria_from_comment",
471+
new_callable=AsyncMock,
472+
return_value=None,
473+
)
462474
@patch("kagan.core.integrations.github._gh_view_issue", new_callable=AsyncMock)
463475
@patch("kagan.core.integrations.github._gh_fetch_issues", new_callable=AsyncMock)
464476
@patch(
@@ -595,3 +607,61 @@ async def test_preview_raises_when_gh_missing(_mock_path, integration, config, c
595607

596608
with pytest.raises(KaganError, match=r"gh.*not found"):
597609
await integration.preview(client, config, client.active_project_id)
610+
611+
612+
@patch("kagan.core.integrations.github._gh_fetch_issues", new_callable=AsyncMock)
613+
@patch(
614+
"kagan.core.integrations.github._gh_is_authenticated", new_callable=AsyncMock, return_value=True
615+
)
616+
@patch("kagan.core.integrations.github._gh_path", return_value="/usr/bin/gh")
617+
async def test_sync_raises_when_no_repo_attached(
618+
_mock_path, _mock_auth, mock_fetch, integration, config, tmp_path
619+
) -> None:
620+
"""sync() raises KaganError when project has no repositories attached."""
621+
from kagan.core.errors import KaganError
622+
623+
# Create a fresh client with a project that has NO repos attached
624+
client = KaganCore(db_path=tmp_path / "no_repo_test.db")
625+
project = await client.projects.create("No Repo Project")
626+
await client.projects.set_active(project.id)
627+
628+
mock_fetch.return_value = _make_gh_issues((1, "Bug report", ["bug"]))
629+
630+
try:
631+
with pytest.raises(KaganError, match=r"No repositories attached"):
632+
await integration.sync(client, config, project.id)
633+
finally:
634+
client.close()
635+
636+
637+
@patch("kagan.core.integrations.github._gh_fetch_issues", new_callable=AsyncMock)
638+
@patch(
639+
"kagan.core.integrations.github._gh_is_authenticated", new_callable=AsyncMock, return_value=True
640+
)
641+
@patch("kagan.core.integrations.github._gh_path", return_value="/usr/bin/gh")
642+
async def test_sync_raises_when_multiple_repos_and_none_selected(
643+
_mock_path, _mock_auth, mock_fetch, integration, config, tmp_path
644+
) -> None:
645+
"""sync() raises KaganError when project has multiple repos but none is selected."""
646+
from kagan.core.errors import KaganError
647+
648+
# Create a fresh client with a project that has multiple repos but none selected
649+
client = KaganCore(db_path=tmp_path / "multi_repo_test.db")
650+
project = await client.projects.create("Multi Repo Project")
651+
await client.projects.set_active(project.id)
652+
653+
# Attach two repos
654+
repo1_path = tmp_path / "repo1"
655+
repo2_path = tmp_path / "repo2"
656+
await make_git_repo(repo1_path)
657+
await make_git_repo(repo2_path)
658+
await client.projects.add_repo(project.id, str(repo1_path))
659+
await client.projects.add_repo(project.id, str(repo2_path))
660+
661+
mock_fetch.return_value = _make_gh_issues((1, "Bug report", ["bug"]))
662+
663+
try:
664+
with pytest.raises(KaganError, match=r"Multiple repositories attached"):
665+
await integration.sync(client, config, project.id)
666+
finally:
667+
client.close()

0 commit comments

Comments
 (0)