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 = () => ( + + {loading && ( + + + + )} + + {error && ( + + {error} + + )} + + {!loading && !error && ( + + {milestones.length === 0 && ( + + There are no milestones available. + + )} + {milestones.length > 0 && + milestones.map((milestone) => ( + + ))} + + )} + + ); + return ( - +
{ display: 'flex', alignItems: 'center', gap: '1rem', - background: 'transparent', }} > { style={{ height: '100px', width: 'auto', - background: 'transparent', - backgroundColor: 'transparent', display: 'block', }} /> @@ -286,6 +377,15 @@ const App = () => {
+
-
+
{comment.reactions?.total_count > 0 && (
{reactionsLoading && ( diff --git a/frontend/src/components/CommentCard.test.jsx b/frontend/src/components/CommentCard.test.jsx index a576c3e..4d233b4 100644 --- a/frontend/src/components/CommentCard.test.jsx +++ b/frontend/src/components/CommentCard.test.jsx @@ -16,8 +16,23 @@ describe('CommentCard', () => { }; it('renders comment body as HTML', () => { - render(); + const { container } = render(); expect(screen.getByText(/Hi @sjmonson/i)).toBeInTheDocument(); + expect(container.querySelector('.markdown-body')).toBeInTheDocument(); + }); + + it('keeps list and heading markup inside markdown-body', () => { + const commentWithStructure = { + ...mockComment, + body_html: + '

Notes

  • First item
  • Second item
', + }; + const { container } = render( + + ); + const markdown = container.querySelector('.markdown-body'); + expect(markdown.querySelector('h2')).toHaveTextContent('Notes'); + expect(markdown.querySelectorAll('ul li')).toHaveLength(2); }); it('renders user information', () => { diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx index 07b2513..2f1bade 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -1,4 +1,5 @@ // Generated-by: Cursor +// Assisted-by: Cursor import React, { useState, useEffect, useRef, useCallback } from 'react'; import { ExpandableSection, @@ -17,6 +18,11 @@ import { CodeBranchIcon, CaretDownIcon, CaretRightIcon, + LayerGroupIcon, + CatalogIcon, + TaskIcon, + ExclamationTriangleIcon, + PlusIcon, } from '@patternfly/react-icons'; import { getDaysSince, formatDate } from '../utils/dateUtils'; import { @@ -31,10 +37,15 @@ import { fetchAssignees, setIssueAssignees, removeIssueAssignees, + adoptParentMilestone, + createComment, + closeIssueWithComment, + createIssue, } from '../services/api'; import CommentCard from './CommentCard'; import Reactions from './Reactions'; import UserAvatar from './UserAvatar'; +import MarkdownInputModal from './MarkdownInputModal'; import labelsCache, { clearLabelsCache } from '../utils/labelsCache'; import milestonesCache from '../utils/milestonesCache'; import assigneesCache from '../utils/assigneesCache'; @@ -77,13 +88,35 @@ const getTypeContrastColor = (colorName) => { return darkColors.includes(colorName.toLowerCase()) ? '#ffffff' : '#000000'; }; -const IssueCard = ({ issue, onMilestoneChange, onIssueUpdate }) => { +const IssueCard = ({ + issue, + onMilestoneChange, + onLabelsChange, + onIssueUpdate, + enableHierarchy = false, + columnCount = 7, + isHierarchyExpanded = false, + onToggleHierarchy, + isDropTarget = false, + isDragging = false, + onDragStartIssue, + onDragOverIssue, + onDragEndIssue, + onAdoptParentMilestone, + onIssueClosed, + onIssueCreated, + milestoneNumber = null, +}) => { const daysSince = getDaysSince(issue.created_at); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isCommentsExpanded, setIsCommentsExpanded] = useState(false); const [comments, setComments] = useState([]); const [commentsLoading, setCommentsLoading] = useState(false); const [commentsError, setCommentsError] = useState(null); + // Track whether the comments list has been fetched. Do not use + // comments.length === 0 alone: adding a comment before expand leaves + // length > 0 and would skip loading pre-existing comments. + const [commentsLoaded, setCommentsLoaded] = useState(false); const [currentLabels, setCurrentLabels] = useState(issue.labels || []); const [availableLabels, setAvailableLabels] = useState([]); const [isLabelMenuOpen, setIsLabelMenuOpen] = useState(false); @@ -117,13 +150,16 @@ const IssueCard = ({ issue, onMilestoneChange, onIssueUpdate }) => { const [pendingAssignees, setPendingAssignees] = useState([]); // Temporary selection while dropdown is open const assigneesMenuRef = useRef(null); const assigneesToggleRef = useRef(null); + const [isCommentModalOpen, setIsCommentModalOpen] = useState(false); + const [isCreateSubIssueOpen, setIsCreateSubIssueOpen] = useState(false); + const [commentCount, setCommentCount] = useState(issue.comments || 0); useEffect(() => { if ( isCommentsExpanded && - comments.length === 0 && + !commentsLoaded && !commentsLoading && - issue.comments > 0 + commentCount > 0 ) { setCommentsLoading(true); setCommentsError(null); @@ -131,20 +167,26 @@ const IssueCard = ({ issue, onMilestoneChange, onIssueUpdate }) => { .then((data) => { setComments(data); setCommentsLoading(false); + setCommentsLoaded(true); }) .catch((err) => { setCommentsError(err.message); setCommentsLoading(false); + setCommentsLoaded(true); }); } }, [ isCommentsExpanded, issue.number, - issue.comments, - comments.length, + commentCount, + commentsLoaded, commentsLoading, ]); + useEffect(() => { + setCommentCount(issue.comments || 0); + }, [issue.comments]); + // Collapse comments when description is collapsed useEffect(() => { if (!isDescriptionExpanded) { @@ -525,12 +567,20 @@ const IssueCard = ({ issue, onMilestoneChange, onIssueUpdate }) => { handleApplyAssignees, ]); + const notifyLabelsChanged = () => { + if (!onLabelsChange) return; + const milestoneNumber = + currentMilestone?.number ?? issue.milestone?.number ?? 0; + onLabelsChange({ milestoneNumber }); + }; + const handleRemoveLabel = async (labelName) => { try { await removeLabel(issue.number, labelName); setCurrentLabels( currentLabels.filter((label) => label.name !== labelName) ); + notifyLabelsChanged(); } catch (err) { console.error('Failed to remove label:', err); setLabelsError(err.message); @@ -551,6 +601,7 @@ const IssueCard = ({ issue, onMilestoneChange, onIssueUpdate }) => { currentLabels.filter((label) => label.name !== labelName) ); } + notifyLabelsChanged(); } catch (err) { console.error('Failed to toggle label:', err); setLabelsError(err.message); @@ -715,8 +766,49 @@ const IssueCard = ({ issue, onMilestoneChange, onIssueUpdate }) => { const getCommentsToggleText = () => { return isCommentsExpanded - ? `Hide Comments (${issue.comments})` - : `Show Comments (${issue.comments})`; + ? `Hide Comments (${commentCount})` + : `Show Comments (${commentCount})`; + }; + + const handleAddComment = async (body) => { + const created = await createComment(issue.number, body); + setComments((prev) => [...prev, created]); + setCommentCount((prev) => prev + 1); + setIsCommentsExpanded(true); + // If comments were never fetched but some already exist, leave + // commentsLoaded false so the expand effect loads the full list. + if (commentsLoaded || commentCount === 0) { + setCommentsLoaded(true); + } + if (onIssueUpdate) { + onIssueUpdate({ ...issue, comments: (commentCount || 0) + 1 }); + } + }; + + const handleCloseWithComment = async (body) => { + await closeIssueWithComment(issue.number, body); + onIssueClosed?.({ issueNumber: issue.number }); + }; + + const handleCreateSubIssue = async (payload) => { + const created = await createIssue({ + ...payload, + milestone: + milestoneNumber ?? + currentMilestone?.number ?? + issue.milestone?.number ?? + undefined, + parent_number: issue.number, + }); + onIssueCreated?.({ + issue: created, + parentNumber: issue.number, + milestoneNumber: + milestoneNumber ?? + currentMilestone?.number ?? + issue.milestone?.number ?? + 0, + }); }; // Column 4: PR icon, "closed by #", or blank @@ -768,32 +860,164 @@ const IssueCard = ({ issue, onMilestoneChange, onIssueUpdate }) => { borderBottom: '1px solid #d2d2d2', }; - return ( - <> - - {/* Column 1: Expansion icon */} - - {issue.body_html ? ( + const depth = issue.hierarchy_depth || 0; + const childCount = issue.child_count ?? (issue.children || []).length; + const indentPx = enableHierarchy ? depth * 16 : 0; + + const handleAdoptParentMilestone = async () => { + try { + const result = await adoptParentMilestone(issue.number); + onAdoptParentMilestone?.({ + fromMilestoneNumber: result.from_milestone ?? currentMilestone?.number, + toMilestoneNumber: result.to_milestone, + }); + } catch (err) { + console.error('Failed to adopt parent milestone:', err); + } + }; + + const renderHierarchyType = () => { + if (!enableHierarchy) return null; + let Icon = LayerGroupIcon; + let label = 'Epic'; + if (depth === 1) { + Icon = CatalogIcon; + label = 'Story'; + } else if (depth >= 2) { + Icon = TaskIcon; + label = 'Sub-story'; + } + return ( + +
+ {childCount > 0 && ( - ) : null} + )} + + + + {depth === 0 && childCount > 0 && ( + + {childCount} + + )} + + + + + + {issue.external_parent && ( + + + + + + + )} +
+ + ); + }; + + const rowStyle = { + backgroundColor: isDropTarget + ? '#e7f1fa' + : isDragging + ? '#f0f0f0' + : undefined, + opacity: isDragging ? 0.6 : 1, + }; + + return ( + <> + + {/* Column 1: Expansion icon */} + + {/* Column 2: Issue number link */} - + { - {/* Column 3: Owner avatar, creation date, assigned chiclet stacked */} + {renderHierarchyType()} + + {/* Column: Owner avatar, creation date, assigned chiclet stacked */}
{ {/* Expanded description and comments - only shown when expanded */} - {issue.body_html && isDescriptionExpanded && ( + {isDescriptionExpanded && ( -
- {issue.comments > 0 && ( -
- setIsCommentsExpanded(!isCommentsExpanded)} - isExpanded={isCommentsExpanded} - > -
- {commentsLoading && ( -
- + {issue.body_html ? ( +
+ ) : ( +

+ No description. +

+ )} +
+ setIsCommentsExpanded(!isCommentsExpanded)} + isExpanded={isCommentsExpanded} + > +
+ {commentsLoading && ( +
+ +
+ )} + {commentsError && ( + + {commentsError} + + )} + {!commentsLoading && + !commentsError && + comments.length > 0 && ( +
+ {comments.map((comment) => ( + + ))}
)} - {commentsError && ( - - {commentsError} - + {!commentsLoading && + !commentsError && + comments.length === 0 && + isCommentsExpanded && ( +

+ No comments found. +

)} - {!commentsLoading && - !commentsError && - comments.length > 0 && ( -
- {comments.map((comment) => ( - - ))} -
- )} - {!commentsLoading && - !commentsError && - comments.length === 0 && - isCommentsExpanded && ( -

- No comments found. -

- )} -
-
+
+ +
+
- )} +
{issue.reactions?.total_count > 0 && (
{reactionsLoading && ( @@ -1556,6 +1805,24 @@ const IssueCard = ({ issue, onMilestoneChange, onIssueUpdate }) => { )} + setIsCommentModalOpen(false)} + mode="comment" + title={`Comment on #${issue.number}`} + submitLabel="Submit" + showCloseWithComment + onSubmit={handleAddComment} + onCloseWithComment={handleCloseWithComment} + /> + setIsCreateSubIssueOpen(false)} + mode="issue" + title={`Add sub-issue under #${issue.number}`} + submitLabel="Create Issue" + onSubmit={handleCreateSubIssue} + /> { }); }); - it('does not show expansion icon when body is absent', async () => { + it('shows expansion icon when body is absent (for comments)', async () => { const issueWithoutBody = { ...mockIssue, body: null, body_html: null }; await act(async () => { render(); }); await waitFor(() => { expect( - screen.queryByRole('button', { name: /show description/i }) - ).not.toBeInTheDocument(); + screen.getByRole('button', { name: /show description/i }) + ).toBeInTheDocument(); }); }); it('expands and shows HTML body when expansion icon is clicked', async () => { const user = userEvent.setup(); - render(); + const { container } = render(); const expandButton = screen.getByRole('button', { name: /show description/i, }); @@ -117,6 +117,7 @@ describe('IssueCard', () => { expect( screen.getByRole('button', { name: /hide description/i }) ).toBeInTheDocument(); + expect(container.querySelector('.markdown-body')).toBeInTheDocument(); }); }); @@ -417,4 +418,241 @@ describe('IssueCard', () => { }); }); }); + + it('calls onLabelsChange when a label is removed', async () => { + const user = userEvent.setup(); + const onLabelsChange = vi.fn(); + api.removeLabel.mockResolvedValue({}); + const issueWithMilestone = { + ...mockIssue, + milestone: { number: 6, title: 'Current' }, + }; + + await act(async () => { + render( + + + + +
+ ); + }); + + await user.click( + screen.getByRole('button', { name: 'Remove enhancement label' }) + ); + + await waitFor(() => { + expect(api.removeLabel).toHaveBeenCalledWith(459, 'enhancement'); + expect(onLabelsChange).toHaveBeenCalledWith({ milestoneNumber: 6 }); + }); + }); + + it('calls onLabelsChange with milestone 0 when issue has no milestone', async () => { + const user = userEvent.setup(); + const onLabelsChange = vi.fn(); + api.removeLabel.mockResolvedValue({}); + const issueWithoutMilestone = { + ...mockIssue, + milestone: null, + }; + + await act(async () => { + render( + + + + +
+ ); + }); + + await user.click( + screen.getByRole('button', { name: 'Remove enhancement label' }) + ); + + await waitFor(() => { + expect(onLabelsChange).toHaveBeenCalledWith({ milestoneNumber: 0 }); + }); + }); + + it('shows Epic type icon and child count when hierarchy enabled', async () => { + const epic = { + ...mockIssue, + hierarchy_depth: 0, + child_count: 2, + children: [{ number: 2 }, { number: 3 }], + }; + await act(async () => { + render( + + + + +
+ ); + }); + expect(screen.getByLabelText('Epic')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Show sub-issues' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Add sub-issue' }) + ).toBeInTheDocument(); + }); + + it('shows Add Comment under expanded description', async () => { + const user = userEvent.setup(); + await act(async () => { + render(); + }); + await user.click(screen.getByRole('button', { name: /show description/i })); + expect( + screen.getByRole('button', { name: /show comments/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Add Comment' }) + ).toBeInTheDocument(); + }); + + it('opens comment modal from Add Comment', async () => { + const user = userEvent.setup(); + await act(async () => { + render(); + }); + await user.click(screen.getByRole('button', { name: /show description/i })); + await user.click(screen.getByRole('button', { name: 'Add Comment' })); + expect(screen.getByText('Comment on #459')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Close with Comment' }) + ).toBeInTheDocument(); + }); + + it('loads existing comments when adding a comment before expanding', async () => { + const user = userEvent.setup(); + const existingComment = { + id: 1, + body: 'Pre-existing comment', + body_html: '

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(); + }); + + await user.click(screen.getByRole('button', { name: /show description/i })); + await user.click(screen.getByRole('button', { name: 'Add Comment' })); + await user.type( + screen.getByPlaceholderText('Write markdown…'), + 'Brand new comment' + ); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => { + expect(api.createComment).toHaveBeenCalledWith(459, 'Brand new comment'); + expect(api.fetchComments).toHaveBeenCalledWith(459); + expect(screen.getByText('Pre-existing comment')).toBeInTheDocument(); + expect(screen.getByText('Brand new comment')).toBeInTheDocument(); + }); + }); + + it('shows Story and Sub-story icons by depth', async () => { + await act(async () => { + render( + + + + + +
+ ); + }); + expect(screen.getByLabelText('Story')).toBeInTheDocument(); + expect(screen.getByLabelText('Sub-story')).toBeInTheDocument(); + }); + + it('calls adoptParentMilestone for external parent', async () => { + const user = userEvent.setup(); + const onAdopt = vi.fn(); + api.adoptParentMilestone.mockResolvedValue({ + from_milestone: 1, + to_milestone: 2, + }); + const issue = { + ...mockIssue, + hierarchy_depth: 0, + child_count: 0, + external_parent: { + number: 99, + title: 'Other', + milestone: { number: 2, title: 'M2' }, + }, + }; + await act(async () => { + render( + + + + +
+ ); + }); + await user.click(screen.getByText('Match parent milestone')); + await waitFor(() => { + expect(api.adoptParentMilestone).toHaveBeenCalledWith(459); + expect(onAdopt).toHaveBeenCalledWith({ + fromMilestoneNumber: 1, + toMilestoneNumber: 2, + }); + }); + }); }); diff --git a/frontend/src/components/MarkdownInputModal.jsx b/frontend/src/components/MarkdownInputModal.jsx new file mode 100644 index 0000000..4e11789 --- /dev/null +++ b/frontend/src/components/MarkdownInputModal.jsx @@ -0,0 +1,462 @@ +// Generated-by: Cursor +import React, { useState, useEffect, useRef } from 'react'; +import { + Modal, + Button, + Tabs, + Tab, + TabTitleText, + TextInput, + TextArea, + Form, + FormGroup, + Checkbox, + Spinner, + Alert, + Tooltip, +} from '@patternfly/react-core'; +import { renderMarkdown, fetchLabels, fetchAssignees } from '../services/api'; +import labelsCache from '../utils/labelsCache'; +import assigneesCache from '../utils/assigneesCache'; +import UserAvatar from './UserAvatar'; + +const ISSUE_TYPES = ['Bug', 'Feature']; + +/** + * Popup input window with Edit / Preview markdown tabs. + * + * Modes: + * - comment: body only; actions Submit + optional Close with Comment + Cancel + * - issue: title, type, labels, assignees + body; action Submit + Cancel + * + * Generated-by: Cursor + */ +const MarkdownInputModal = ({ + isOpen, + onClose, + mode = 'comment', + title = 'Add Comment', + submitLabel = 'Submit', + onSubmit, + showCloseWithComment = false, + onCloseWithComment, +}) => { + const [activeTab, setActiveTab] = useState(0); + const [body, setBody] = useState(''); + const [issueTitle, setIssueTitle] = useState(''); + const [issueType, setIssueType] = useState('Feature'); + const [selectedLabels, setSelectedLabels] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState([]); + const [availableLabels, setAvailableLabels] = useState([]); + const [availableAssignees, setAvailableAssignees] = useState([]); + const [labelsLoading, setLabelsLoading] = useState(false); + const [assigneesLoading, setAssigneesLoading] = useState(false); + const [previewHtml, setPreviewHtml] = useState(''); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const textareaRef = useRef(null); + const previewRequestId = useRef(0); + + const resetState = () => { + setActiveTab(0); + setBody(''); + setIssueTitle(''); + setIssueType('Feature'); + setSelectedLabels([]); + setSelectedAssignees([]); + setPreviewHtml(''); + setPreviewError(null); + setSubmitError(null); + setIsSubmitting(false); + }; + + useEffect(() => { + if (!isOpen) { + resetState(); + return; + } + if (mode !== 'issue') return; + + // Load labels + if (labelsCache.data.length > 0) { + setAvailableLabels(labelsCache.data); + } else { + setLabelsLoading(true); + fetchLabels() + .then((data) => { + labelsCache.data = data; + setAvailableLabels(data); + setLabelsLoading(false); + }) + .catch(() => setLabelsLoading(false)); + } + + // Load assignees + if (assigneesCache.data.length > 0) { + setAvailableAssignees(assigneesCache.data); + } else { + setAssigneesLoading(true); + fetchAssignees() + .then((data) => { + assigneesCache.data = data; + setAvailableAssignees(data); + setAssigneesLoading(false); + }) + .catch(() => setAssigneesLoading(false)); + } + }, [isOpen, mode]); + + useEffect(() => { + if (!isOpen || activeTab !== 1) return; + + const requestId = ++previewRequestId.current; + setPreviewLoading(true); + setPreviewError(null); + + renderMarkdown(body || '') + .then((data) => { + if (previewRequestId.current !== requestId) return; + setPreviewHtml(data.html || ''); + setPreviewLoading(false); + }) + .catch((err) => { + if (previewRequestId.current !== requestId) return; + setPreviewError(err.message); + setPreviewLoading(false); + }); + }, [isOpen, activeTab, body]); + + const handleClose = () => { + if (isSubmitting) return; + onClose?.(); + }; + + const buildIssuePayload = () => ({ + title: issueTitle.trim(), + body: body || undefined, + type: issueType || undefined, + labels: selectedLabels.length > 0 ? selectedLabels : undefined, + assignees: selectedAssignees.length > 0 ? selectedAssignees : undefined, + }); + + const handleSubmit = async () => { + if (mode === 'issue' && !issueTitle.trim()) { + setSubmitError('Title is required'); + return; + } + if (mode === 'comment' && !body.trim()) { + setSubmitError('Comment body is required'); + return; + } + setIsSubmitting(true); + setSubmitError(null); + try { + if (mode === 'issue') { + await onSubmit?.(buildIssuePayload()); + } else { + await onSubmit?.(body); + } + onClose?.(); + } catch (err) { + setSubmitError(err.message || 'Submit failed'); + } finally { + setIsSubmitting(false); + } + }; + + const handleCloseWithComment = async () => { + if (!body.trim()) { + setSubmitError('Comment body is required'); + return; + } + setIsSubmitting(true); + setSubmitError(null); + try { + await onCloseWithComment?.(body); + onClose?.(); + } catch (err) { + setSubmitError(err.message || 'Close with comment failed'); + } finally { + setIsSubmitting(false); + } + }; + + const toggleLabel = (name, checked) => { + setSelectedLabels((prev) => + checked ? [...prev, name] : prev.filter((n) => n !== name) + ); + }; + + const toggleAssignee = (login, checked) => { + setSelectedAssignees((prev) => + checked ? [...prev, login] : prev.filter((n) => n !== login) + ); + }; + + const actions = [ + , + ]; + + if (showCloseWithComment && mode === 'comment') { + actions.push( + + ); + } + + actions.push( + + ); + + return ( + +
+ {mode === 'issue' && ( + <> + + { + const stringValue = + typeof value === 'string' + ? value + : value?.target?.value || ''; + setIssueTitle(stringValue); + setSubmitError(null); + }} + placeholder="Issue title" + isRequired + /> + + +
+ {ISSUE_TYPES.map((typeName) => ( + + ))} +
+
+ + {labelsLoading ? ( + + ) : ( +
+ {availableLabels.map((label) => { + const checked = selectedLabels.includes(label.name); + return ( + +
+ + toggleLabel(label.name, isChecked) + } + label={ + + + {label.name} + + } + /> +
+
+ ); + })} + {availableLabels.length === 0 && ( + + No labels available + + )} +
+ )} +
+ + {assigneesLoading ? ( + + ) : ( +
+ {availableAssignees.map((assignee) => { + const checked = selectedAssignees.includes(assignee.login); + return ( + + toggleAssignee(assignee.login, isChecked) + } + label={ + + + {assignee.login} + + } + /> + ); + })} + {availableAssignees.length === 0 && ( + + No assignees available + + )} +
+ )} +
+ + )} + + + setActiveTab(key)} + style={{ marginBottom: '0.5rem' }} + > + Edit}> +