Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 140 additions & 6 deletions kennel/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,38 @@ def _graphql(self, query: str, **variables: Any) -> Any:
raise GraphQLError(data["errors"])
return data

def _graphql_paginate(
self,
query: str,
connection_path: tuple[str, ...],
**variables: Any,
) -> Iterator[Any]:
"""Yield every node from a paginated GraphQL connection.

The *query* must accept a ``$cursor: String`` variable and request
both ``nodes { ... }`` and ``pageInfo { endCursor hasNextPage }``
on the connection of interest.

*connection_path* names the connection's position in the response:
e.g. ``("repository", "issues")`` walks to
``data.repository.issues`` and yields each node from every page.

Callers pass the initial variables (other than cursor) as keyword
arguments. The helper takes care of supplying ``cursor`` on
subsequent requests.
"""
cursor: str | None = None
while True:
data = self._graphql(query, cursor=cursor, **variables)
node = data["data"]
for key in connection_path:
node = node[key]
yield from node["nodes"]
page = node["pageInfo"]
if not page["hasNextPage"]:
return
cursor = page["endCursor"]

def add_reaction(
self, repo: str, comment_type: str, comment_id: int | str, content: str
) -> None:
Expand Down Expand Up @@ -379,17 +411,105 @@ def set_user_status(self, msg: str, emoji: str, busy: bool = True) -> None:
)
self._graphql(query, msg=msg, emoji=emoji, busy=busy)

_ISSUE_NODE_FIELDS = (
"number title createdAt "
"milestone{title} "
"assignees(first:20){nodes{login}} "
"parent{number}"
)

def find_issues(self, owner: str, repo: str, login: str) -> list[dict[str, Any]]:
"""Return open issues assigned to login (oldest first) with sub-issue states."""
"""Return open issues assigned to *login* (oldest first).

Each node carries ``number``, ``title``, ``createdAt``,
``milestone.title``, ``assignees.nodes[].login``, ``parent.number``
(or None), and a paginated-and-hydrated ``subIssues.nodes`` list
in GitHub rank order.
"""
issue_fields = self._ISSUE_NODE_FIELDS
query = (
"query($owner:String!,$repo:String!,$login:String!){"
"query($owner:String!,$repo:String!,$login:String!,$cursor:String){"
"repository(owner:$owner,name:$repo){"
"issues(first:50,states:[OPEN],filterBy:{assignee:$login},"
"issues(first:50,after:$cursor,states:[OPEN],"
"filterBy:{assignee:$login},"
"orderBy:{field:CREATED_AT,direction:ASC}){"
"nodes{number title subIssues(first:10){nodes{state}}}}}}"
f"nodes{{{issue_fields} state "
f"subIssues(first:50){{nodes{{state {issue_fields}}} "
"pageInfo{endCursor hasNextPage}}}"
"pageInfo{endCursor hasNextPage}"
"}}}"
)
data = self._graphql(query, owner=owner, repo=repo, login=login)
return data["data"]["repository"]["issues"]["nodes"]
issues: list[dict[str, Any]] = []
for node in self._graphql_paginate(
query,
("repository", "issues"),
owner=owner,
repo=repo,
login=login,
):
if node.get("subIssues", {}).get("pageInfo", {}).get("hasNextPage"):
node["subIssues"]["nodes"] = list(
self.get_sub_issues(owner, repo, node["number"])
)
issues.append(node)
return issues

def get_issue_node(self, owner: str, repo: str, number: int) -> dict[str, Any]:
"""Return one issue in the shape used by :meth:`find_issues`.

Used by the picker's upward walk: call with any issue number and
get back the same dict shape so descent code can keep walking.
"""
issue_fields = self._ISSUE_NODE_FIELDS
query = (
"query($owner:String!,$repo:String!,$number:Int!){"
"repository(owner:$owner,name:$repo){"
"issue(number:$number){"
f"state {issue_fields} "
f"subIssues(first:50){{nodes{{state {issue_fields}}} "
"pageInfo{endCursor hasNextPage}}"
"}}}"
)
data = self._graphql(query, owner=owner, repo=repo, number=number)
node = data["data"]["repository"]["issue"]
if node.get("subIssues", {}).get("pageInfo", {}).get("hasNextPage"):
node["subIssues"]["nodes"] = list(self.get_sub_issues(owner, repo, number))
return node

def get_sub_issues(
self, owner: str, repo: str, number: int
) -> Iterator[dict[str, Any]]:
"""Yield the direct sub-issues of *number* in GitHub rank order.

Each node has the same shape as a node from :meth:`find_issues`
(``number``, ``title``, ``state``, ``createdAt``, ``milestone.title``,
``assignees.nodes[].login``).
"""
issue_fields = self._ISSUE_NODE_FIELDS
query = (
"query($owner:String!,$repo:String!,$number:Int!,$cursor:String){"
"repository(owner:$owner,name:$repo){"
"issue(number:$number){"
"subIssues(first:50,after:$cursor){"
f"nodes{{state {issue_fields}}} "
"pageInfo{endCursor hasNextPage}"
"}}}}"
)
yield from self._graphql_paginate(
query,
("repository", "issue", "subIssues"),
owner=owner,
repo=repo,
number=number,
)

def add_assignee(self, repo: str, number: int | str, login: str) -> None:
"""Assign *login* to issue *number* in *repo*.

Uses the REST endpoint so assignment is additive — existing
assignees are preserved.
"""
self._post(f"/repos/{repo}/issues/{number}/assignees", assignees=[login])

def view_issue(self, repo: str, number: int | str) -> dict[str, Any]:
"""Return issue data (state, title, body, created_at)."""
Expand Down Expand Up @@ -694,6 +814,20 @@ def view_issue(self, repo: str, number: int | str) -> dict[str, Any]:
"""Return issue data (state, title, body)."""
return self._gh.view_issue(repo, number)

def get_sub_issues(
self, owner: str, repo: str, number: int
) -> list[dict[str, Any]]:
"""Return the direct sub-issues of *number*, in GitHub rank order."""
return list(self._gh.get_sub_issues(owner, repo, number))

def get_issue_node(self, owner: str, repo: str, number: int) -> dict[str, Any]:
"""Return one issue in the shape used by :meth:`find_issues`."""
return self._gh.get_issue_node(owner, repo, number)

def add_assignee(self, repo: str, number: int | str, login: str) -> None:
"""Assign *login* to issue *number*."""
self._gh.add_assignee(repo, number, login)

def comment_issue(self, repo: str, number: int | str, body: str) -> None:
"""Post a comment on an issue."""
self._gh.comment_issue(repo, number, body)
Expand Down
Loading
Loading