diff --git a/backend/src/github_pm/api.py b/backend/src/github_pm/api.py index 4693daf..fd43675 100644 --- a/backend/src/github_pm/api.py +++ b/backend/src/github_pm/api.py @@ -11,6 +11,14 @@ import requests from github_pm.context import context +from github_pm.issue_hierarchy import ( + apply_graphql_hierarchy, + build_issue_forest, + collect_descendant_numbers, + is_ancestor, + ISSUE_HIERARCHY_GRAPHQL, + PARENT_ONLY_GRAPHQL, +) from github_pm.logger import logger api_router = APIRouter() @@ -142,6 +150,25 @@ def post( ) return response.json() + def post_text( + self, path: str, text: str, headers: dict[str, str] | None = None + ) -> str: + """POST plain text (e.g. ``/markdown/raw``); returns response body as text. + + Generated-by: Cursor + """ + request_headers = {"Content-Type": "text/plain; charset=utf-8"} + if headers: + request_headers.update(headers) + response = self._with_504_retry( + lambda: self.github.post( + f"{self.base_url}{path}", + data=text.encode("utf-8"), + headers=request_headers, + ) + ) + return response.text + def delete( self, path: str, @@ -185,6 +212,26 @@ async def get_project(): } +def _sort_items_by_labels(items: list[dict], sort_by: list[str]) -> list[dict]: + """Sort open items by label priority, then by number within each bucket. + + Assisted-by: Cursor + """ + buckets: dict[str, list] = defaultdict(list) + for item in items: + labels = {label["name"].lower() for label in item["labels"]} + for label in sort_by: + if label in labels: + buckets[label].append(item) + break + else: + buckets["other"].append(item) + ordered: list[dict] = [] + for label in sort_by + ["other"]: + ordered.extend(sorted(buckets[label], key=lambda x: x["number"])) + return ordered + + @api_router.get("/issues/{milestone_number}") async def get_issues( gitctx: Annotated[Connector, Depends(connection)], @@ -193,76 +240,71 @@ async def get_issues( str | None, Query(title="Sort", description="List of labels to sort by") ] = None, ): + """Return open issues (as a sub-issue forest) and PRs for a milestone. + + Issues are nested via GitHub parent/sub-issue links discovered over GraphQL. + Pull requests remain a flat list. Each sibling list is sorted by optional + label criteria. + Assisted-by: Cursor + """ if sort: sort_by = [s.strip() for s in sort.split(",")] else: sort_by = [] - sorted_issues = defaultdict(list) start = time.time() milestone = "none" if milestone_number == 0 else milestone_number - issues = gitctx.get_paged( + raw_items = gitctx.get_paged( f"/repos/{context.github_repo}/issues?milestone={milestone}&state=open", headers={"Accept": "application/vnd.github.html+json"}, ) - for i in issues: - labels = set([label["name"].lower() for label in i["labels"]]) - if "pull_request" not in i: - query = """query($owner: String!, $repo: String!, $issue: Int!) { - repository(owner: $owner, name: $repo, followRenames: true) { - issue(number: $issue) { - closedByPullRequestsReferences(first: 100, includeClosedPrs: true) { - nodes { - number - title - url - } - } - } - } - } - """ - try: - response = gitctx.post( - "/graphql", - data={ - "query": query, - "variables": { - "owner": gitctx.owner, - "repo": gitctx.repo, - "issue": i["number"], - }, + issues: list[dict] = [] + pull_requests: list[dict] = [] + for i in raw_items: + if "pull_request" in i: + pull_requests.append(i) + continue + try: + response = gitctx.post( + "/graphql", + data={ + "query": ISSUE_HIERARCHY_GRAPHQL, + "variables": { + "owner": gitctx.owner, + "repo": gitctx.repo, + "issue": i["number"], }, - ) - data = response["data"] - issue_node = data["repository"]["issue"] - closed = issue_node["closedByPullRequestsReferences"]["nodes"] - if len(closed) > 0: - i["closed_by"] = [ - { - "number": linked["number"], - "title": linked["title"], - "url": linked["url"], - } - for linked in closed - ] - except Exception as e: - logger.exception( - f"Error finding linked PRs for issue {i['number']}: {e!r}" - ) - continue - for label in sort_by: - if label in labels: - sorted_issues[label].append(i) - break - else: - sorted_issues["other"].append(i) - all_issues = [] - for label in sort_by + ["other"]: - all_issues.extend(sorted(sorted_issues[label], key=lambda x: x["number"])) + }, + ) + data = response["data"] + issue_node = data["repository"]["issue"] + closed_refs = issue_node.get("closedByPullRequestsReferences") or {} + closed = closed_refs.get("nodes") or [] + if len(closed) > 0: + i["closed_by"] = [ + { + "number": linked["number"], + "title": linked["title"], + "url": linked["url"], + } + for linked in closed + ] + apply_graphql_hierarchy(i, issue_node) + except Exception as e: + logger.exception( + f"Error enriching hierarchy/PRs for issue {i['number']}: {e!r}" + ) + continue + issues.append(i) + forest = build_issue_forest(issues, sort_by, _sort_items_by_labels) + sorted_prs = _sort_items_by_labels(pull_requests, sort_by) logger.debug( - f"{len(issues)}({len(all_issues)}) issues: {time.time() - start:.3f} seconds" + "%s(%s issues, %s PRs) items: %.3f seconds", + len(raw_items), + len(forest), + len(sorted_prs), + time.time() - start, ) - return all_issues + return {"issues": forest, "pull_requests": sorted_prs} @api_router.get("/issue/{issue_number}") @@ -336,6 +378,158 @@ async def get_comments( return comments +class CreateComment(BaseModel): + """Body for creating an issue comment. + + Generated-by: Cursor + """ + + body: str = Field(title="Comment Body", min_length=1) + + +@api_router.post("/comments/{issue_number}") +async def create_comment( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], + comment: Annotated[CreateComment, Body(title="Comment")], +): + """Create a comment on an issue. + + Generated-by: Cursor + """ + created = gitctx.post( + f"/repos/{context.github_repo}/issues/{issue_number}/comments", + data={"body": comment.body}, + headers={"Accept": "application/vnd.github.html+json"}, + ) + logger.info("Created comment on issue #%s", issue_number) + return created + + +class CloseWithComment(BaseModel): + """Body for closing an issue with an optional comment. + + Generated-by: Cursor + """ + + body: str = Field(title="Comment Body", min_length=1) + + +@api_router.post("/issues/{issue_number}/close-with-comment") +async def close_issue_with_comment( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], + comment: Annotated[CloseWithComment, Body(title="Comment")], +): + """Add a comment to an issue and mark it closed. + + Generated-by: Cursor + """ + created_comment = gitctx.post( + f"/repos/{context.github_repo}/issues/{issue_number}/comments", + data={"body": comment.body}, + headers={"Accept": "application/vnd.github.html+json"}, + ) + closed_issue = gitctx.patch( + f"/repos/{context.github_repo}/issues/{issue_number}", + data={"state": "closed"}, + headers={"Accept": "application/vnd.github.html+json"}, + ) + logger.info("Closed issue #%s with comment", issue_number) + return {"comment": created_comment, "issue": closed_issue} + + +class RenderMarkdown(BaseModel): + """Body for rendering markdown via GitHub. + + Generated-by: Cursor + """ + + text: str = Field(title="Markdown Text", default="") + + +@api_router.post("/markdown") +async def render_markdown( + gitctx: Annotated[Connector, Depends(connection)], + payload: Annotated[RenderMarkdown, Body(title="Markdown")], +): + """Render markdown to HTML using GitHub ``POST /markdown/raw``. + + Generated-by: Cursor + """ + html = gitctx.post_text("/markdown/raw", payload.text) + return {"html": html} + + +class CreateIssue(BaseModel): + """Body for creating a repository issue (optionally as a sub-issue). + + Generated-by: Cursor + """ + + title: str = Field(title="Issue Title", min_length=1) + body: str | None = Field(default=None, title="Issue Body") + labels: list[str] | None = Field(default=None, title="Labels") + assignees: list[str] | None = Field(default=None, title="Assignees") + type: str | None = Field( + default=None, + title="Issue Type", + description="GitHub issue type name, e.g. Bug or Feature", + ) + milestone: int | None = Field(default=None, title="Milestone Number") + parent_number: int | None = Field( + default=None, + title="Parent Issue Number", + description="When set, link the new issue as a sub-issue of this parent", + ) + + +@api_router.post("/issues") +async def create_issue( + gitctx: Annotated[Connector, Depends(connection)], + issue: Annotated[CreateIssue, Body(title="Issue")], +): + """Create an issue; optionally assign milestone and/or parent. + + Generated-by: Cursor + """ + data: dict[str, Any] = {"title": issue.title} + if issue.body is not None: + data["body"] = issue.body + if issue.labels is not None: + data["labels"] = issue.labels + if issue.assignees is not None: + data["assignees"] = issue.assignees + if issue.type is not None: + data["type"] = issue.type + if issue.milestone is not None and issue.milestone != 0: + data["milestone"] = issue.milestone + + created = gitctx.post( + f"/repos/{context.github_repo}/issues", + data=data, + headers={"Accept": "application/vnd.github.html+json"}, + ) + logger.info("Created issue #%s: %s", created.get("number"), issue.title) + + parent_number = issue.parent_number + if parent_number is not None: + if parent_number == created["number"]: + raise HTTPException( + status_code=422, detail="An issue cannot be its own parent" + ) + gitctx.post( + f"/repos/{context.github_repo}/issues/{parent_number}/sub_issues", + data={"sub_issue_id": created["id"], "replace_parent": True}, + ) + logger.info( + "Linked issue #%s as sub-issue of #%s", created["number"], parent_number + ) + created["parent_number"] = parent_number + + return created + + @api_router.get("/issues/{issue_number}/reactions") async def get_issue_reactions( gitctx: Annotated[Connector, Depends(connection)], @@ -454,6 +648,178 @@ async def remove_milestone_from_issue( return issue +def _list_sub_issue_numbers(gitctx: Connector, issue_number: int) -> list[int]: + """List child issue numbers via REST; fall back to empty on failure. + + Generated-by: Cursor + """ + try: + items = gitctx.get_paged( + f"/repos/{context.github_repo}/issues/{issue_number}/sub_issues" + ) + return [item["number"] for item in items if "number" in item] + except Exception as e: + logger.exception("Failed listing sub-issues for #%s: %r", issue_number, e) + return [] + + +def _cascade_milestone( + gitctx: Connector, + root_number: int, + milestone_number: int | None, +) -> list[int]: + """Set milestone on root and all GitHub descendants. Returns updated numbers. + + Generated-by: Cursor + """ + + def list_children(n: int) -> list[int]: + return _list_sub_issue_numbers(gitctx, n) + + numbers = [root_number] + collect_descendant_numbers(list_children, root_number) + updated: list[int] = [] + for number in numbers: + gitctx.patch( + f"/repos/{context.github_repo}/issues/{number}", + data={"milestone": milestone_number}, + ) + updated.append(number) + return updated + + +def _graphql_issue_node(gitctx: Connector, issue_number: int) -> dict: + response = gitctx.post( + "/graphql", + data={ + "query": PARENT_ONLY_GRAPHQL, + "variables": { + "owner": gitctx.owner, + "repo": gitctx.repo, + "issue": issue_number, + }, + }, + ) + data = response.get("data") or {} + repo = data.get("repository") or {} + issue_node = repo.get("issue") + if not issue_node: + raise HTTPException(status_code=404, detail=f"Issue #{issue_number} not found") + return issue_node + + +class SetIssueParent(BaseModel): + parent_number: int = Field(title="Parent Issue Number") + + +@api_router.put("/issues/{issue_number}/parent") +async def set_issue_parent( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], + body: Annotated[SetIssueParent, Body(title="Parent")], +): + """Link issue as a sub-issue of parent, cascading milestone when needed. + + Generated-by: Cursor + """ + parent_number = body.parent_number + if parent_number == issue_number: + raise HTTPException(status_code=422, detail="An issue cannot be its own parent") + + def list_children(n: int) -> list[int]: + return _list_sub_issue_numbers(gitctx, n) + + if is_ancestor(list_children, issue_number, parent_number): + raise HTTPException( + status_code=422, + detail="Cannot link an issue under one of its descendants", + ) + + child = gitctx.get(f"/repos/{context.github_repo}/issues/{issue_number}") + parent = gitctx.get(f"/repos/{context.github_repo}/issues/{parent_number}") + + gitctx.post( + f"/repos/{context.github_repo}/issues/{parent_number}/sub_issues", + data={"sub_issue_id": child["id"], "replace_parent": True}, + ) + + child_ms = (child.get("milestone") or {}).get("number") + parent_ms = (parent.get("milestone") or {}).get("number") + updated_issue_numbers: list[int] = [] + if child_ms != parent_ms: + # parent_ms may be None (no milestone); still cascade to match parent. + updated_issue_numbers = _cascade_milestone(gitctx, issue_number, parent_ms) + + return { + "issue_number": issue_number, + "parent_number": parent_number, + "from_milestone": child_ms, + "to_milestone": parent_ms, + "updated_issue_numbers": updated_issue_numbers, + } + + +@api_router.delete("/issues/{issue_number}/parent") +async def clear_issue_parent( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], +): + """Unlink issue from its current parent (becomes an epic). + + Generated-by: Cursor + """ + child = gitctx.get(f"/repos/{context.github_repo}/issues/{issue_number}") + issue_node = _graphql_issue_node(gitctx, issue_number) + parent = issue_node.get("parent") + if not parent or parent.get("number") is None: + raise HTTPException( + status_code=404, detail=f"Issue #{issue_number} has no parent" + ) + parent_number = parent["number"] + gitctx.delete( + f"/repos/{context.github_repo}/issues/{parent_number}/sub_issue", + data={"sub_issue_id": child["id"]}, + ) + return { + "issue_number": issue_number, + "parent_number": parent_number, + "message": "parent cleared", + } + + +@api_router.post("/issues/{issue_number}/adopt-parent-milestone") +async def adopt_parent_milestone( + gitctx: Annotated[Connector, Depends(connection)], + issue_number: Annotated[int, Path(title="Issue")], +): + """Move this issue and descendants to its GitHub parent's milestone. + + Generated-by: Cursor + """ + issue = gitctx.get(f"/repos/{context.github_repo}/issues/{issue_number}") + issue_node = _graphql_issue_node(gitctx, issue_number) + parent = issue_node.get("parent") + if not parent or parent.get("number") is None: + raise HTTPException( + status_code=404, detail=f"Issue #{issue_number} has no parent" + ) + parent_ms_info = parent.get("milestone") + if not parent_ms_info or parent_ms_info.get("number") is None: + raise HTTPException( + status_code=422, + detail=f"Parent #{parent['number']} has no milestone to adopt", + ) + to_milestone = parent_ms_info["number"] + from_milestone = (issue.get("milestone") or {}).get("number") + updated = _cascade_milestone(gitctx, issue_number, to_milestone) + return { + "issue_number": issue_number, + "parent_number": parent["number"], + "from_milestone": from_milestone, + "to_milestone": to_milestone, + "updated_issue_numbers": updated, + } + + # """Label Management""" diff --git a/backend/src/github_pm/issue_hierarchy.py b/backend/src/github_pm/issue_hierarchy.py new file mode 100644 index 0000000..14096f6 --- /dev/null +++ b/backend/src/github_pm/issue_hierarchy.py @@ -0,0 +1,241 @@ +"""Build and mutate GitHub sub-issue hierarchies for the planner. + +Generated-by: Cursor +""" + +from __future__ import annotations + +from typing import Any, Callable + +from github_pm.logger import logger + +SortFn = Callable[[list[dict], list[str]], list[dict]] + + +def _external_parent_payload(parent: dict[str, Any] | None) -> dict[str, Any] | None: + """Normalize a GraphQL parent node into the API's external_parent shape.""" + if not parent or parent.get("number") is None: + return None + milestone = parent.get("milestone") + return { + "number": parent["number"], + "title": parent.get("title"), + "milestone": ( + { + "number": milestone.get("number"), + "title": milestone.get("title"), + } + if isinstance(milestone, dict) + else None + ), + } + + +def _would_create_cycle( + child_number: int, + parent_number: int, + parent_of: dict[int, int], +) -> bool: + """Return True if linking child under parent would create a cycle.""" + if child_number == parent_number: + return True + current: int | None = parent_number + seen: set[int] = set() + while current is not None: + if current == child_number: + return True + if current in seen: + break + seen.add(current) + current = parent_of.get(current) + return False + + +def build_issue_forest( + issues: list[dict], + sort_by: list[str], + sort_fn: SortFn, +) -> list[dict]: + """Nest open milestone issues into a forest using GraphQL parent links. + + Issues whose parent is outside this open set become display roots with + ``external_parent`` set. Cyclic edges are broken by treating them as + external/orphan roots. + + Assisted-by: Cursor + """ + by_number: dict[int, dict] = {} + for issue in issues: + node = dict(issue) + node["children"] = [] + node["parent_number"] = None + node["external_parent"] = None + node["hierarchy_depth"] = 0 + node["child_count"] = 0 + by_number[node["number"]] = node + + # Proposed in-tree edges: child -> parent when parent is in this set. + parent_of: dict[int, int] = {} + external: dict[int, dict[str, Any]] = {} + + for number, node in by_number.items(): + parent_info = node.get("_parent_info") + if not parent_info or parent_info.get("number") is None: + continue + parent_number = parent_info["number"] + if parent_number in by_number: + parent_of[number] = parent_number + else: + external[number] = _external_parent_payload(parent_info) + + # Drop cyclic edges. + for child, parent in list(parent_of.items()): + if _would_create_cycle( + child, parent, {k: v for k, v in parent_of.items() if k != child} + ): + logger.warning("Breaking cyclic sub-issue edge #%s -> #%s", child, parent) + parent_info = by_number[child].get("_parent_info") + external[child] = _external_parent_payload(parent_info) or { + "number": parent, + "title": None, + "milestone": None, + } + del parent_of[child] + + children_map: dict[int, list[int]] = {n: [] for n in by_number} + for child, parent in parent_of.items(): + children_map[parent].append(child) + by_number[child]["parent_number"] = parent + + for number, payload in external.items(): + by_number[number]["external_parent"] = payload + + def attach(number: int, depth: int) -> dict: + node = by_number[number] + node["hierarchy_depth"] = depth + child_nodes = [attach(c, depth + 1) for c in children_map[number]] + node["children"] = sort_fn(child_nodes, sort_by) + node["child_count"] = len(node["children"]) + # Strip internal enrichment key before returning. + node.pop("_parent_info", None) + return node + + roots = [number for number in by_number if number not in parent_of] + forest = [attach(n, 0) for n in roots] + return sort_fn(forest, sort_by) + + +def apply_graphql_hierarchy(issue: dict, issue_node: dict | None) -> None: + """Attach parent / sub-issue summary fields from a GraphQL issue node. + + Generated-by: Cursor + """ + if not issue_node: + return + parent = issue_node.get("parent") + if parent: + issue["_parent_info"] = { + "number": parent.get("number"), + "title": parent.get("title"), + "milestone": parent.get("milestone"), + } + summary = issue_node.get("subIssuesSummary") + if summary: + issue["sub_issues_summary"] = { + "total": summary.get("total", 0), + "completed": summary.get("completed", 0), + "percent_completed": summary.get("percentCompleted", 0), + } + + +ISSUE_HIERARCHY_GRAPHQL = """ +query($owner: String!, $repo: String!, $issue: Int!) { + repository(owner: $owner, name: $repo, followRenames: true) { + issue(number: $issue) { + closedByPullRequestsReferences(first: 100, includeClosedPrs: true) { + nodes { + number + title + url + } + } + parent { + number + title + milestone { + number + title + } + } + subIssues(first: 100) { + nodes { + number + } + } + subIssuesSummary { + total + completed + percentCompleted + } + } + } +} +""" + +PARENT_ONLY_GRAPHQL = """ +query($owner: String!, $repo: String!, $issue: Int!) { + repository(owner: $owner, name: $repo, followRenames: true) { + issue(number: $issue) { + id + number + parent { + number + title + milestone { + number + title + } + } + subIssues(first: 100) { + nodes { + number + } + } + } + } +} +""" + + +def collect_descendant_numbers( + list_sub_issues: Callable[[int], list[int]], + root_number: int, +) -> list[int]: + """BFS over GitHub sub-issues starting at root (excluding root itself). + + Generated-by: Cursor + """ + descendants: list[int] = [] + queue = list(list_sub_issues(root_number)) + seen: set[int] = {root_number} + while queue: + number = queue.pop(0) + if number in seen: + continue + seen.add(number) + descendants.append(number) + queue.extend(list_sub_issues(number)) + return descendants + + +def is_ancestor( + list_sub_issues: Callable[[int], list[int]], + ancestor_number: int, + descendant_number: int, +) -> bool: + """True if ancestor_number is an ancestor of descendant_number via sub-issues.""" + if ancestor_number == descendant_number: + return True + return descendant_number in collect_descendant_numbers( + list_sub_issues, ancestor_number + ) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 75f64e9..943bbcb 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -13,11 +13,19 @@ from github_pm.api import ( add_label_to_issue, add_milestone_to_issue, + adopt_parent_milestone, api_router, + clear_issue_parent, + close_issue_with_comment, + CloseWithComment, connection, Connector, + create_comment, + create_issue, create_label, create_milestone, + CreateComment, + CreateIssue, CreateLabel, CreateMilestone, delete_label, @@ -31,6 +39,10 @@ get_project, remove_label_from_issue, remove_milestone_from_issue, + render_markdown, + RenderMarkdown, + set_issue_parent, + SetIssueParent, ) from github_pm.app import app @@ -206,14 +218,70 @@ async def test_get_issues_with_milestone(self): result = await get_issues(mock_gitctx, milestone_number=1) # Assert - assert len(result) == 2 - assert result[0]["id"] == 1 - assert result[1]["id"] == 2 + assert len(result["issues"]) == 1 + assert len(result["pull_requests"]) == 1 + assert result["pull_requests"][0]["id"] == 1 + assert result["issues"][0]["id"] == 2 + assert result["issues"][0]["hierarchy_depth"] == 0 + assert result["issues"][0]["children"] == [] + assert result["issues"][0]["child_count"] == 0 mock_gitctx.get_paged.assert_called_once() # Issue 1 has pull_request, so no GraphQL call # Issue 2 doesn't have pull_request, so GraphQL should be called assert mock_gitctx.post.call_count == 1 + @pytest.mark.asyncio + async def test_get_issues_builds_hierarchy(self): + """Test nesting when GraphQL reports an in-milestone parent.""" + mock_issues = [ + {"id": 10, "number": 10, "title": "Epic", "labels": []}, + {"id": 20, "number": 20, "title": "Story", "labels": []}, + ] + + def graphql_side_effect(*_args, **kwargs): + issue_num = kwargs["data"]["variables"]["issue"] + parent = None + if issue_num == 20: + parent = { + "number": 10, + "title": "Epic", + "milestone": {"number": 1, "title": "M1"}, + } + return { + "data": { + "repository": { + "issue": { + "closedByPullRequestsReferences": {"nodes": []}, + "parent": parent, + "subIssues": {"nodes": []}, + "subIssuesSummary": { + "total": 0, + "completed": 0, + "percentCompleted": 0, + }, + } + } + } + } + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_issues) + mock_gitctx.post = Mock(side_effect=graphql_side_effect) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await get_issues(mock_gitctx, milestone_number=1) + + assert len(result["issues"]) == 1 + epic = result["issues"][0] + assert epic["number"] == 10 + assert epic["child_count"] == 1 + assert epic["children"][0]["number"] == 20 + assert epic["children"][0]["hierarchy_depth"] == 1 + assert epic["children"][0]["parent_number"] == 10 + @pytest.mark.asyncio async def test_get_issues_with_linked_prs(self): """Test getting issues with linked PRs from GraphQL.""" @@ -263,17 +331,18 @@ async def test_get_issues_with_linked_prs(self): result = await get_issues(mock_gitctx, milestone_number=1) # Assert - assert len(result) == 1 - assert result[0]["id"] == 1 - assert "closed_by" in result[0] - assert len(result[0]["closed_by"]) == 2 - assert result[0]["closed_by"][0]["number"] == 123 - assert result[0]["closed_by"][0]["title"] == "Fix Issue 1" + assert len(result["issues"]) == 1 + assert result["pull_requests"] == [] + assert result["issues"][0]["id"] == 1 + assert "closed_by" in result["issues"][0] + assert len(result["issues"][0]["closed_by"]) == 2 + assert result["issues"][0]["closed_by"][0]["number"] == 123 + assert result["issues"][0]["closed_by"][0]["title"] == "Fix Issue 1" assert ( - result[0]["closed_by"][0]["url"] + result["issues"][0]["closed_by"][0]["url"] == "https://github.com/test/repo/pull/123" ) - assert result[0]["closed_by"][1]["number"] == 456 + assert result["issues"][0]["closed_by"][1]["number"] == 456 mock_gitctx.post.assert_called_once() @pytest.mark.asyncio @@ -310,8 +379,9 @@ async def test_get_issues_with_no_milestone(self): result = await get_issues(mock_gitctx, milestone_number=0) # Assert - assert len(result) == 1 - assert result[0]["id"] == 1 + assert len(result["issues"]) == 1 + assert result["pull_requests"] == [] + assert result["issues"][0]["id"] == 1 mock_gitctx.get_paged.assert_called_once() # GraphQL should be called for issue without pull_request assert mock_gitctx.post.call_count == 1 @@ -362,13 +432,14 @@ async def test_get_issues_with_sort_single_label(self): result = await get_issues(mock_gitctx, milestone_number=0, sort="bug") # Assert - assert len(result) == 3 + assert len(result["issues"]) == 3 + assert result["pull_requests"] == [] # First should be bug issue - assert result[0]["id"] == 1 - assert result[0]["title"] == "Bug Issue" + assert result["issues"][0]["id"] == 1 + assert result["issues"][0]["title"] == "Bug Issue" # Then other issues - assert result[1]["id"] == 2 - assert result[2]["id"] == 3 + assert result["issues"][1]["id"] == 2 + assert result["issues"][2]["id"] == 3 @pytest.mark.asyncio async def test_get_issues_with_sort_multiple_labels(self): @@ -424,12 +495,13 @@ async def test_get_issues_with_sort_multiple_labels(self): ) # Assert - assert len(result) == 4 + assert len(result["issues"]) == 4 + assert result["pull_requests"] == [] # Order should be: bug, feature, enhancement, other - assert result[0]["id"] == 1 # bug - assert result[1]["id"] == 2 # feature - assert result[2]["id"] == 3 # enhancement - assert result[3]["id"] == 4 # other (unlabeled) + assert result["issues"][0]["id"] == 1 # bug + assert result["issues"][1]["id"] == 2 # feature + assert result["issues"][2]["id"] == 3 # enhancement + assert result["issues"][3]["id"] == 4 # other (unlabeled) @pytest.mark.asyncio async def test_get_issues_with_sort_case_insensitive(self): @@ -473,10 +545,11 @@ async def test_get_issues_with_sort_case_insensitive(self): ) # Assert - assert len(result) == 2 + assert len(result["issues"]) == 2 + assert result["pull_requests"] == [] # Should match despite case difference - assert result[0]["id"] == 1 # bug (matched "BUG") - assert result[1]["id"] == 2 # feature (matched "Feature") + assert result["issues"][0]["id"] == 1 # bug (matched "BUG") + assert result["issues"][1]["id"] == 2 # feature (matched "Feature") @pytest.mark.asyncio async def test_get_issues_with_sort_whitespace_stripped(self): @@ -520,10 +593,11 @@ async def test_get_issues_with_sort_whitespace_stripped(self): ) # Assert - assert len(result) == 2 + assert len(result["issues"]) == 2 + assert result["pull_requests"] == [] # Should match despite whitespace - assert result[0]["id"] == 1 # bug - assert result[1]["id"] == 2 # feature + assert result["issues"][0]["id"] == 1 # bug + assert result["issues"][1]["id"] == 2 # feature @pytest.mark.asyncio async def test_get_issues_with_sort_first_match_wins(self): @@ -570,10 +644,11 @@ async def test_get_issues_with_sort_first_match_wins(self): ) # Assert - assert len(result) == 2 + assert len(result["issues"]) == 2 + assert result["pull_requests"] == [] # Issue 1 should be in bug category (first match), not feature - assert result[0]["id"] == 1 # bug (first match wins) - assert result[1]["id"] == 2 # feature + assert result["issues"][0]["id"] == 1 # bug (first match wins) + assert result["issues"][1]["id"] == 2 # feature @pytest.mark.asyncio async def test_get_issues_with_sort_all_other(self): @@ -615,10 +690,71 @@ async def test_get_issues_with_sort_all_other(self): result = await get_issues(mock_gitctx, milestone_number=0, sort=None) # Assert - assert len(result) == 2 + assert len(result["issues"]) == 2 + assert result["pull_requests"] == [] # Both should be in "other" since no sort labels specified - assert result[0]["id"] == 1 - assert result[1]["id"] == 2 + assert result["issues"][0]["id"] == 1 + assert result["issues"][1]["id"] == 2 + + @pytest.mark.asyncio + async def test_get_issues_sorts_issues_and_prs_separately(self): + """Issues and PRs are sorted independently by the same label criteria.""" + # Arrange + mock_items = [ + { + "id": 1, + "number": 1, + "title": "Bug Issue", + "labels": [{"name": "bug"}], + }, + { + "id": 2, + "number": 2, + "title": "Feature Issue", + "labels": [{"name": "feature"}], + }, + { + "id": 10, + "number": 10, + "title": "Feature PR", + "pull_request": {}, + "labels": [{"name": "feature"}], + }, + { + "id": 11, + "number": 11, + "title": "Bug PR", + "pull_request": {}, + "labels": [{"name": "bug"}], + }, + ] + + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=mock_items) + mock_gitctx.post = Mock( + return_value={ + "data": { + "repository": { + "issue": {"closedByPullRequestsReferences": {"nodes": []}} + } + } + } + ) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + + # Act + result = await get_issues( + mock_gitctx, milestone_number=1, sort="bug,feature" + ) + + # Assert: each list sorted independently (bug then feature) + assert [i["id"] for i in result["issues"]] == [1, 2] + assert [p["id"] for p in result["pull_requests"]] == [11, 10] + assert mock_gitctx.post.call_count == 2 class TestGetComments: @@ -838,6 +974,192 @@ async def test_remove_milestone_from_issue(self): ) +class TestSetIssueParent: + """Test PUT /issues/{n}/parent.""" + + @pytest.mark.asyncio + async def test_set_parent_same_milestone(self): + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get = Mock( + side_effect=[ + {"id": 200, "number": 20, "milestone": {"number": 1}}, + {"id": 100, "number": 10, "milestone": {"number": 1}}, + ] + ) + mock_gitctx.get_paged = Mock(return_value=[]) + mock_gitctx.post = Mock(return_value={}) + mock_gitctx.patch = Mock() + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await set_issue_parent( + mock_gitctx, 20, SetIssueParent(parent_number=10) + ) + + assert result["parent_number"] == 10 + assert result["updated_issue_numbers"] == [] + mock_gitctx.post.assert_called_once_with( + "/repos/test/repo/issues/10/sub_issues", + data={"sub_issue_id": 200, "replace_parent": True}, + ) + mock_gitctx.patch.assert_not_called() + + @pytest.mark.asyncio + async def test_set_parent_cascades_milestone(self): + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get = Mock( + side_effect=[ + {"id": 200, "number": 20, "milestone": {"number": 1}}, + {"id": 100, "number": 10, "milestone": {"number": 2}}, + ] + ) + mock_gitctx.get_paged = Mock( + side_effect=[ + [], # cycle check: children of 20 + [{"number": 30}], # cascade: children of 20 + [], # cascade: children of 30 + ] + ) + mock_gitctx.post = Mock(return_value={}) + mock_gitctx.patch = Mock(return_value={}) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await set_issue_parent( + mock_gitctx, 20, SetIssueParent(parent_number=10) + ) + + assert result["from_milestone"] == 1 + assert result["to_milestone"] == 2 + assert result["updated_issue_numbers"] == [20, 30] + assert mock_gitctx.patch.call_count == 2 + + @pytest.mark.asyncio + async def test_set_parent_rejects_self(self): + mock_gitctx = Mock(spec=Connector) + with pytest.raises(HTTPException) as exc: + await set_issue_parent(mock_gitctx, 5, SetIssueParent(parent_number=5)) + assert exc.value.status_code == 422 + + @pytest.mark.asyncio + async def test_set_parent_rejects_descendant(self): + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get_paged = Mock(return_value=[{"number": 10}]) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + with pytest.raises(HTTPException) as exc: + await set_issue_parent( + mock_gitctx, 20, SetIssueParent(parent_number=10) + ) + assert exc.value.status_code == 422 + + +class TestClearIssueParent: + """Test DELETE /issues/{n}/parent.""" + + @pytest.mark.asyncio + async def test_clear_parent(self): + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get = Mock(return_value={"id": 200, "number": 20}) + mock_gitctx.post = Mock( + return_value={ + "data": { + "repository": { + "issue": { + "number": 20, + "parent": {"number": 10, "title": "Epic"}, + "subIssues": {"nodes": []}, + } + } + } + } + ) + mock_gitctx.delete = Mock(return_value={}) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await clear_issue_parent(mock_gitctx, 20) + + assert result["parent_number"] == 10 + mock_gitctx.delete.assert_called_once_with( + "/repos/test/repo/issues/10/sub_issue", + data={"sub_issue_id": 200}, + ) + + @pytest.mark.asyncio + async def test_clear_parent_missing(self): + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get = Mock(return_value={"id": 200, "number": 20}) + mock_gitctx.post = Mock( + return_value={ + "data": { + "repository": { + "issue": { + "number": 20, + "parent": None, + "subIssues": {"nodes": []}, + } + } + } + } + ) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" + + with pytest.raises(HTTPException) as exc: + await clear_issue_parent(mock_gitctx, 20) + assert exc.value.status_code == 404 + + +class TestAdoptParentMilestone: + """Test POST adopt-parent-milestone.""" + + @pytest.mark.asyncio + async def test_adopt_cascades(self): + mock_gitctx = Mock(spec=Connector) + mock_gitctx.get = Mock( + return_value={"id": 200, "number": 20, "milestone": {"number": 1}} + ) + mock_gitctx.post = Mock( + return_value={ + "data": { + "repository": { + "issue": { + "number": 20, + "parent": { + "number": 10, + "title": "Epic", + "milestone": {"number": 2, "title": "M2"}, + }, + "subIssues": {"nodes": [{"number": 30}]}, + } + } + } + } + ) + mock_gitctx.get_paged = Mock( + side_effect=[ + [{"number": 30}], + [], + ] + ) + mock_gitctx.patch = Mock(return_value={}) + mock_gitctx.owner = "test" + mock_gitctx.repo = "repo" + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await adopt_parent_milestone(mock_gitctx, 20) + + assert result["from_milestone"] == 1 + assert result["to_milestone"] == 2 + assert result["updated_issue_numbers"] == [20, 30] + assert mock_gitctx.patch.call_count == 2 + + class TestGetLabels: """Test the get_labels endpoint.""" @@ -1310,3 +1632,206 @@ def test_follows_next_link(self): assert items == [{"number": 1}, {"number": 2}] assert mock_session.get.call_count == 2 + + +class TestCreateComment: + """Test the create_comment endpoint. + + Generated-by: Cursor + """ + + @pytest.mark.asyncio + async def test_create_comment_success(self): + mock_comment = { + "id": 99, + "body": "Hello", + "body_html": "
Hello
", + } + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_comment) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await create_comment(mock_gitctx, 42, CreateComment(body="Hello")) + + assert result == mock_comment + mock_gitctx.post.assert_called_once_with( + "/repos/test/repo/issues/42/comments", + data={"body": "Hello"}, + headers={"Accept": "application/vnd.github.html+json"}, + ) + + +class TestCloseIssueWithComment: + """Test the close_issue_with_comment endpoint. + + Generated-by: Cursor + """ + + @pytest.mark.asyncio + async def test_close_with_comment_success(self): + mock_comment = {"id": 1, "body": "Done"} + mock_issue = {"number": 42, "state": "closed"} + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_comment) + mock_gitctx.patch = Mock(return_value=mock_issue) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await close_issue_with_comment( + mock_gitctx, 42, CloseWithComment(body="Done") + ) + + assert result == {"comment": mock_comment, "issue": mock_issue} + mock_gitctx.post.assert_called_once() + mock_gitctx.patch.assert_called_once_with( + "/repos/test/repo/issues/42", + data={"state": "closed"}, + headers={"Accept": "application/vnd.github.html+json"}, + ) + + +class TestRenderMarkdown: + """Test the render_markdown endpoint. + + Generated-by: Cursor + """ + + @pytest.mark.asyncio + async def test_render_markdown_success(self): + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post_text = Mock(return_value="hi
") + + result = await render_markdown(mock_gitctx, RenderMarkdown(text="**hi**")) + + assert result == {"html": "hi
"} + mock_gitctx.post_text.assert_called_once_with("/markdown/raw", "**hi**") + + @pytest.mark.asyncio + async def test_render_markdown_empty(self): + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post_text = Mock(return_value="") + + result = await render_markdown(mock_gitctx, RenderMarkdown(text="")) + + assert result == {"html": ""} + mock_gitctx.post_text.assert_called_once_with("/markdown/raw", "") + + +class TestCreateIssue: + """Test the create_issue endpoint. + + Generated-by: Cursor + """ + + @pytest.mark.asyncio + async def test_create_issue_basic(self): + mock_created = {"id": 100, "number": 50, "title": "New"} + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_created) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await create_issue( + mock_gitctx, + CreateIssue(title="New", body="Body text", type="Bug"), + ) + + assert result == mock_created + call_args = mock_gitctx.post.call_args + assert call_args[0][0] == "/repos/test/repo/issues" + assert call_args[1]["data"]["title"] == "New" + assert call_args[1]["data"]["body"] == "Body text" + assert call_args[1]["data"]["type"] == "Bug" + assert mock_gitctx.post.call_count == 1 + + @pytest.mark.asyncio + async def test_create_issue_with_milestone_labels_assignees(self): + mock_created = {"id": 100, "number": 50, "title": "Epic"} + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_created) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await create_issue( + mock_gitctx, + CreateIssue( + title="Epic", + labels=["enhancement"], + assignees=["alice"], + milestone=6, + type="Feature", + ), + ) + + assert result["number"] == 50 + data = mock_gitctx.post.call_args[1]["data"] + assert data["labels"] == ["enhancement"] + assert data["assignees"] == ["alice"] + assert data["milestone"] == 6 + assert data["type"] == "Feature" + + @pytest.mark.asyncio + async def test_create_issue_skips_milestone_zero(self): + mock_created = {"id": 100, "number": 50, "title": "No MS"} + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_created) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + await create_issue(mock_gitctx, CreateIssue(title="No MS", milestone=0)) + + assert "milestone" not in mock_gitctx.post.call_args[1]["data"] + + @pytest.mark.asyncio + async def test_create_issue_with_parent(self): + mock_created = {"id": 100, "number": 50, "title": "Child"} + mock_gitctx = Mock(spec=Connector) + mock_gitctx.post = Mock(return_value=mock_created) + + with patch("github_pm.api.context") as mock_context: + mock_context.github_repo = "test/repo" + result = await create_issue( + mock_gitctx, + CreateIssue(title="Child", parent_number=10, milestone=6), + ) + + assert result["parent_number"] == 10 + assert mock_gitctx.post.call_count == 2 + parent_call = mock_gitctx.post.call_args_list[1] + assert parent_call[0][0] == "/repos/test/repo/issues/10/sub_issues" + assert parent_call[1]["data"] == { + "sub_issue_id": 100, + "replace_parent": True, + } + + +class TestConnectorPostText: + """Test Connector.post_text for markdown/raw. + + Generated-by: Cursor + """ + + def test_post_text_returns_response_text(self): + mock_session = Mock() + mock_response = Mock() + mock_response.status_code = 200 + mock_response.raise_for_status = Mock() + mock_response.text = "ok
" + mock_session.post.return_value = mock_response + + with ( + patch("github_pm.api.requests.session", return_value=mock_session), + patch("github_pm.api.context") as mock_context, + ): + mock_context.github_repo = "o/r" + mock_context.github_token = "tok" + conn = Connector("tok", github_repo="o/r") + html = conn.post_text("/markdown/raw", "**ok**") + + assert html == "ok
" + mock_session.post.assert_called_once() + call_kwargs = mock_session.post.call_args + assert call_kwargs[0][0] == "https://api.github.com/markdown/raw" + assert call_kwargs[1]["data"] == b"**ok**" + assert call_kwargs[1]["headers"]["Content-Type"] == "text/plain; charset=utf-8" diff --git a/backend/tests/test_issue_hierarchy.py b/backend/tests/test_issue_hierarchy.py new file mode 100644 index 0000000..1de35b6 --- /dev/null +++ b/backend/tests/test_issue_hierarchy.py @@ -0,0 +1,160 @@ +"""Tests for issue hierarchy forest building. + +Generated-by: Cursor +""" + +from github_pm.api import _sort_items_by_labels +from github_pm.issue_hierarchy import ( + apply_graphql_hierarchy, + build_issue_forest, + collect_descendant_numbers, + is_ancestor, +) + + +def _issue(number: int, labels=None, parent=None, **extra): + item = { + "id": number * 10, + "number": number, + "title": f"Issue {number}", + "labels": labels or [], + **extra, + } + if parent is not None: + item["_parent_info"] = parent + return item + + +class TestBuildIssueForest: + def test_flat_peers_are_epics(self): + issues = [_issue(1), _issue(2), _issue(3)] + forest = build_issue_forest(issues, [], _sort_items_by_labels) + assert [n["number"] for n in forest] == [1, 2, 3] + for node in forest: + assert node["hierarchy_depth"] == 0 + assert node["parent_number"] is None + assert node["external_parent"] is None + assert node["children"] == [] + assert node["child_count"] == 0 + + def test_nests_when_parent_in_set(self): + issues = [ + _issue(1), + _issue(2, parent={"number": 1, "title": "Epic", "milestone": None}), + _issue(3, parent={"number": 2, "title": "Story", "milestone": None}), + ] + forest = build_issue_forest(issues, [], _sort_items_by_labels) + assert len(forest) == 1 + epic = forest[0] + assert epic["number"] == 1 + assert epic["hierarchy_depth"] == 0 + assert epic["child_count"] == 1 + story = epic["children"][0] + assert story["number"] == 2 + assert story["hierarchy_depth"] == 1 + assert story["parent_number"] == 1 + substory = story["children"][0] + assert substory["number"] == 3 + assert substory["hierarchy_depth"] == 2 + assert substory["parent_number"] == 2 + + def test_external_parent_becomes_root(self): + issues = [ + _issue( + 5, + parent={ + "number": 99, + "title": "Other Epic", + "milestone": {"number": 7, "title": "v1"}, + }, + ) + ] + forest = build_issue_forest(issues, [], _sort_items_by_labels) + assert len(forest) == 1 + node = forest[0] + assert node["number"] == 5 + assert node["hierarchy_depth"] == 0 + assert node["parent_number"] is None + assert node["external_parent"] == { + "number": 99, + "title": "Other Epic", + "milestone": {"number": 7, "title": "v1"}, + } + + def test_breaks_cycles(self): + issues = [ + _issue(1, parent={"number": 2, "title": "B", "milestone": None}), + _issue(2, parent={"number": 1, "title": "A", "milestone": None}), + ] + forest = build_issue_forest(issues, [], _sort_items_by_labels) + # One edge breaks; both should appear as roots (or one nested). + numbers = {n["number"] for n in forest} + assert numbers == {1, 2} or (len(forest) == 1 and forest[0]["child_count"] == 1) + # No infinite nesting: depths finite and children don't loop. + for root in forest: + assert root["hierarchy_depth"] == 0 + + def test_label_sort_within_siblings(self): + issues = [ + _issue(1), + _issue( + 3, + labels=[{"name": "bug"}], + parent={"number": 1, "title": "E", "milestone": None}, + ), + _issue( + 2, + labels=[{"name": "feature"}], + parent={"number": 1, "title": "E", "milestone": None}, + ), + _issue(4, labels=[{"name": "bug"}]), + _issue(5, labels=[{"name": "feature"}]), + ] + forest = build_issue_forest(issues, ["bug", "feature"], _sort_items_by_labels) + # Roots: 4 (bug), 5 (feature), 1 (other, with children) + assert [n["number"] for n in forest] == [4, 5, 1] + children = forest[2]["children"] + assert [c["number"] for c in children] == [3, 2] + + def test_strips_internal_parent_info(self): + issues = [ + _issue(1, parent={"number": 99, "title": "X", "milestone": None}), + ] + forest = build_issue_forest(issues, [], _sort_items_by_labels) + assert "_parent_info" not in forest[0] + + +class TestApplyGraphqlHierarchy: + def test_applies_parent_and_summary(self): + issue = {"number": 1} + apply_graphql_hierarchy( + issue, + { + "parent": { + "number": 2, + "title": "P", + "milestone": {"number": 3, "title": "M"}, + }, + "subIssuesSummary": { + "total": 4, + "completed": 1, + "percentCompleted": 25, + }, + }, + ) + assert issue["_parent_info"]["number"] == 2 + assert issue["sub_issues_summary"]["total"] == 4 + assert issue["sub_issues_summary"]["percent_completed"] == 25 + + +class TestCollectDescendants: + def test_bfs_descendants(self): + tree = {1: [2, 3], 2: [4], 3: [], 4: []} + + def list_children(n): + return tree.get(n, []) + + assert collect_descendant_numbers(list_children, 1) == [2, 3, 4] + assert is_ancestor(list_children, 1, 4) is True + assert is_ancestor(list_children, 2, 3) is False + assert is_ancestor(list_children, 1, 1) is True diff --git a/frontend/README.md b/frontend/README.md index feb7d2f..9aebc45 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -87,7 +87,7 @@ Or add `BACKEND_PORT=8080` to a `.env.development` file in this directory. ### API Endpoints - `GET /api/milestones` - Returns list of milestones -- `GET /api/issues/{milestone_number}` - Returns issues for a milestone +- `GET /api/issues/{milestone_number}` - Returns `{ issues, pull_requests }` for a milestone ## Project Structure diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3a4825e..a30c3b0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@patternfly/react-core": "^5.0.0", "@patternfly/react-icons": "^5.0.0", + "github-markdown-css": "^5.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.0", @@ -2754,6 +2755,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-markdown-css": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz", + "integrity": "sha512-8G+PFvqigBQSWLQjyzgpa2ThD9bo7+kDsriUIidGcRhXgmcaAWUIpCZf8DavJgc+xifjbCG+GvMyWr0XMXmc7g==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 235a005..b523af5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,25 +13,26 @@ "format:check": "prettier --check \"src/**/*.{js,jsx,json,css,md}\"" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", "@patternfly/react-core": "^5.0.0", "@patternfly/react-icons": "^5.0.0", + "github-markdown-css": "^5.8.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-markdown": "^9.0.0", "remark-gfm": "^4.0.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.1.0", + "@testing-library/react": "^14.1.0", + "@testing-library/user-event": "^14.5.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", - "vite": "^5.0.0", - "vitest": "^1.0.0", - "@testing-library/react": "^14.1.0", - "@testing-library/jest-dom": "^6.1.0", - "@testing-library/user-event": "^14.5.0", - "jsdom": "^23.0.0", - "@vitest/ui": "^1.0.0", "@vitest/coverage-v8": "^1.0.0", - "prettier": "^3.0.0" + "@vitest/ui": "^1.0.0", + "jsdom": "^23.0.0", + "prettier": "^3.0.0", + "vite": "^5.0.0", + "vitest": "^1.0.0" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d493296..ce4337b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,5 @@ // Generated-by: Cursor +// Assisted-by: Cursor import React, { useState, useEffect } from 'react'; import { Page, @@ -19,6 +20,8 @@ import { fetchAssignees, } from './services/api'; import MilestoneCard from './components/MilestoneCard'; +import PlanningBoard from './components/PlanningBoard'; +import { PlanningDnDProvider } from './components/PlanningDnDContext'; import SdlcKpisPanel from './components/SdlcKpisPanel'; import ProjectStatusPanel from './components/ProjectStatusPanel'; import ManageMilestones from './components/ManageMilestones'; @@ -70,6 +73,44 @@ const App = () => { key: 0, milestoneNumbers: [], }); + // Milestone numbers whose issue/PR lists may be stale after local edits. + const [dirtyMilestoneNumbers, setDirtyMilestoneNumbers] = useState([]); + const [hierarchyAction, setHierarchyAction] = useState(null); + + const markMilestonesDirty = (numbers) => { + const normalized = numbers + .map((n) => (n == null ? 0 : n)) + .filter((n) => typeof n === 'number'); + if (normalized.length === 0) return; + setDirtyMilestoneNumbers((prev) => [...new Set([...prev, ...normalized])]); + }; + + const handleHierarchyChanged = (action) => { + setHierarchyAction({ ...action, key: Date.now() }); + if (action.type === 'relink') { + markMilestonesDirty([ + action.sourceMilestoneNumber, + action.targetMilestoneNumber, + action.fromMilestone, + action.toMilestone, + ]); + } else if (action.type === 'unlink') { + markMilestonesDirty([action.sourceMilestoneNumber]); + } else if (action.type === 'error') { + markMilestonesDirty([ + action.sourceMilestoneNumber, + action.targetMilestoneNumber, + ]); + } + }; + + // MilestoneCards apply hierarchyAction in their effects (children run first). + // Clear afterward so a remount / later hasLoadedOnce transition cannot + // re-apply a stale optimistic mutation and duplicate issues. + useEffect(() => { + if (hierarchyAction == null) return; + setHierarchyAction(null); + }, [hierarchyAction]); useEffect(() => { fetchProject() @@ -191,6 +232,9 @@ const App = () => { } catch (error) { console.error('Failed to save main view tab to localStorage:', error); } + if (activeViewTab !== 'planning') { + setHierarchyAction(null); + } }, [activeViewTab]); const loadMilestones = () => { @@ -227,13 +271,20 @@ const App = () => { fromMilestoneNumber, toMilestoneNumber, }) => { - const nums = [fromMilestoneNumber, toMilestoneNumber].filter( - (n) => n != null - ); + markMilestonesDirty([fromMilestoneNumber, toMilestoneNumber]); + }; + + const handleIssueLabelsChanged = ({ milestoneNumber }) => { + markMilestonesDirty([milestoneNumber]); + }; + + const handleRefreshDirtyMilestones = () => { + if (dirtyMilestoneNumbers.length === 0) return; setIssueMilestoneRefresh((s) => ({ key: s.key + 1, - milestoneNumbers: [...new Set(nums)], + milestoneNumbers: [...dirtyMilestoneNumbers], })); + setDirtyMilestoneNumbers([]); }; const handleLabelChange = () => { @@ -241,19 +292,62 @@ const App = () => { // but we can add a callback if needed in the future }; + const renderPlanningContent = () => ( ++ No description. +
+ )} ++ No comments found. +
)} - {!commentsLoading && - !commentsError && - comments.length > 0 && ( -- No comments found. -
- )} -Pre-existing comment
', + user: { + login: 'alice', + avatar_url: 'https://avatar.url/alice', + }, + created_at: '2025-01-01T00:00:00Z', + }; + const createdComment = { + id: 2, + body: 'Brand new comment', + body_html: 'Brand new comment
', + user: { + login: 'bob', + avatar_url: 'https://avatar.url/bob', + }, + created_at: '2025-01-02T00:00:00Z', + }; + api.createComment.mockResolvedValue(createdComment); + api.fetchComments.mockResolvedValue([existingComment, createdComment]); + + const issueWithComments = { ...mockIssue, comments: 1 }; + await act(async () => { + render(