55from typing import Any , Dict , Iterable , List , Optional , Tuple , Union
66from urllib .parse import quote
77
8- from .errors import ApiError , ConfigError , MoveError , NotFoundError , OwnershipError , ReleaseError , TakeError
8+ from .errors import (
9+ ApiError ,
10+ ConfigError ,
11+ CreateError ,
12+ MoveError ,
13+ NotFoundError ,
14+ OwnershipError ,
15+ ReleaseError ,
16+ TakeError ,
17+ )
918from .github import GitHubClient
1019
1120
140149}
141150"""
142151
152+ ADD_ITEM_MUTATION = """
153+ mutation($projectId: ID!, $contentId: ID!) {
154+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
155+ item { id }
156+ }
157+ }
158+ """
159+
143160COMMENT_VISIBILITY_TIMEOUT_SECONDS = 60.0
144161
145162
@@ -179,29 +196,27 @@ class Project:
179196 def __init__ (
180197 self ,
181198 project : str ,
182- name : str ,
199+ name : Optional [ str ] = None ,
183200 token : Optional [str ] = None ,
184201 has_label : Optional [Union [str , Iterable [str ]]] = None ,
185202 ) -> None :
186203 """Create a Project helper.
187204
188205 Args:
189206 project: Project reference (e.g. "owner/projects/3" or full URL).
190- name: Owner name used for the "owner: <name>" label.
207+ name: Owner name used for the "owner: <name>" label (required for take/move/release) .
191208 token: GitHub token; defaults to GH_TOKEN/GITHUB_TOKEN or gh auth token.
192209 has_label: Optional label filter (string or list). When set, only issues
193210 with at least one matching label are considered by list/take.
194211 """
195212 if not project :
196213 raise ConfigError ("Project reference is required" )
197- if not name :
198- raise ConfigError ("Owner name is required" )
199214 self .client = GitHubClient (token = token )
200215 self .owner , self .number = _parse_project_ref (project )
201216 if self .owner == "@me" :
202217 self .owner = self ._viewer_login ()
203218 self .name = name
204- self .owner_label = f"owner: { self .name } "
219+ self .owner_label = f"owner: { self .name } " if self . name else None
205220 self ._label_filter = _normalize_label_filter (has_label )
206221 self ._project_id : Optional [str ] = None
207222 self ._status_field_id : Optional [str ] = None
@@ -217,6 +232,10 @@ def __enter__(self) -> "Project":
217232 def __exit__ (self , exc_type , exc , tb ) -> bool :
218233 return False
219234
235+ def _require_owner_name (self ) -> None :
236+ if not self .name :
237+ raise ConfigError ("Owner name is required for this operation" )
238+
220239 def statuses (self ) -> List [str ]:
221240 """Return the available status column names."""
222241 self ._ensure_project_loaded ()
@@ -251,6 +270,7 @@ def take(
251270
252271 If a label filter is set, only matching issues are eligible.
253272 """
273+ self ._require_owner_name ()
254274 if issue_id is None and status is None :
255275 raise TakeError ("Provide an issue id or a status to take" )
256276 if status is None and issue_id is not None and _looks_like_status (issue_id ):
@@ -298,6 +318,7 @@ def lease(
298318
299319 def move (self , issue_id : Union [int , str , Issue ], status : str , * , repo : Optional [str ] = None ) -> Issue :
300320 """Move an owned issue to a new status column."""
321+ self ._require_owner_name ()
301322 issue = self ._resolve_issue (issue_id , repo = repo , require_project_item = True )
302323 if not self ._is_owned_by_me (issue ):
303324 raise OwnershipError (f"You do not own issue { issue .repo } #{ issue .number } " )
@@ -334,6 +355,7 @@ def set_number_field(
334355 repo : Optional [str ] = None ,
335356 ) -> Issue :
336357 """Set a numeric project field value on an owned issue."""
358+ self ._require_owner_name ()
337359 issue = self ._resolve_issue (issue_id , repo = repo , require_project_item = True )
338360 if not self ._is_owned_by_me (issue ):
339361 raise OwnershipError (f"You do not own issue { issue .repo } #{ issue .number } " )
@@ -417,6 +439,7 @@ def get_issue(
417439
418440 def release (self , issue_id : Union [int , str , Issue ], * , repo : Optional [str ] = None ) -> Issue :
419441 """Release an owned issue by removing the owner label."""
442+ self ._require_owner_name ()
420443 issue = self ._resolve_issue (issue_id , repo = repo )
421444 if not self ._is_owned_by_me (issue ):
422445 raise OwnershipError (f"You do not own issue { issue .repo } #{ issue .number } " )
@@ -426,6 +449,81 @@ def release(self, issue_id: Union[int, str, Issue], *, repo: Optional[str] = Non
426449 raise ReleaseError (f"Failed to release issue { issue .repo } #{ issue .number } " ) from exc
427450 return issue
428451
452+ def create (
453+ self ,
454+ title : str ,
455+ description : Optional [str ] = None ,
456+ status : str = "Backlog" ,
457+ * ,
458+ repo : Optional [str ] = None ,
459+ label : Optional [str ] = None ,
460+ ) -> Issue :
461+ """Create an issue, add it to the project, and set its status."""
462+ if not title or not str (title ).strip ():
463+ raise ConfigError ("Issue title is required" )
464+ target_repo = repo or self ._infer_repo ()
465+ if not target_repo or "/" not in target_repo :
466+ raise ConfigError ("Repo must be in owner/repo format" )
467+
468+ label_name = label .strip () if label is not None else None
469+ if label is not None and not label_name :
470+ raise ConfigError ("Label must be non-empty" )
471+ if label_name :
472+ self ._ensure_label (target_repo , label_name , description = "Created by gh-task" )
473+
474+ owner , name = target_repo .split ("/" , 1 )
475+ payload : Dict [str , Any ] = {"title" : title , "body" : description or "" }
476+ if label_name :
477+ payload ["labels" ] = [label_name ]
478+
479+ try :
480+ issue_data = self .client .rest ("POST" , f"repos/{ owner } /{ name } /issues" , json_body = payload )
481+ except Exception as exc :
482+ raise CreateError (f"Failed to create issue in { target_repo } " ) from exc
483+
484+ number = issue_data .get ("number" )
485+ content_id = issue_data .get ("node_id" )
486+ if not content_id :
487+ raise CreateError ("Created issue is missing node id" )
488+
489+ self ._ensure_project_loaded ()
490+ status_name , option_id = self ._resolve_status (status )
491+ item_id : Optional [str ] = None
492+ try :
493+ item_data = self .client .graphql (
494+ ADD_ITEM_MUTATION ,
495+ {"projectId" : self ._project_id , "contentId" : content_id },
496+ )
497+ item_id = ((item_data .get ("addProjectV2ItemById" ) or {}).get ("item" ) or {}).get ("id" )
498+ if not item_id :
499+ raise CreateError ("Failed to add issue to project" )
500+ self .client .graphql (
501+ UPDATE_STATUS_MUTATION ,
502+ {
503+ "projectId" : self ._project_id ,
504+ "itemId" : item_id ,
505+ "fieldId" : self ._status_field_id ,
506+ "optionId" : option_id ,
507+ },
508+ )
509+ except CreateError :
510+ raise
511+ except Exception as exc :
512+ raise CreateError (
513+ f"Created issue { target_repo } #{ number } but failed to add to project"
514+ ) from exc
515+
516+ return Issue (
517+ number = number ,
518+ repo = target_repo ,
519+ title = issue_data .get ("title" ) or title ,
520+ url = issue_data .get ("html_url" ),
521+ body = issue_data .get ("body" ) if issue_data .get ("body" ) is not None else (description or "" ),
522+ status = status_name ,
523+ project_item_id = item_id ,
524+ labels = [label_name ] if label_name else None ,
525+ )
526+
429527 def _wrap_take_result (self , issue : Issue , return_issue : bool , lease : bool ):
430528 if lease :
431529 return IssueLease (self , issue )
@@ -586,6 +684,38 @@ def _list_items(self) -> List[Issue]:
586684 cursor = page_info .get ("endCursor" )
587685 return items
588686
687+ def _project_repos (self ) -> List [str ]:
688+ self ._ensure_project_loaded ()
689+ repos : set [str ] = set ()
690+ cursor = None
691+ while True :
692+ data = self .client .graphql (
693+ ITEMS_QUERY ,
694+ {"projectId" : self ._project_id , "cursor" : cursor , "statusField" : self ._status_field_name },
695+ )
696+ node = data .get ("node" ) or {}
697+ project = node or {}
698+ item_block = project .get ("items" ) or {}
699+ for raw in item_block .get ("nodes" ) or []:
700+ content = raw .get ("content" ) or {}
701+ if content .get ("__typename" ) not in {"Issue" , "PullRequest" }:
702+ continue
703+ repo_info = content .get ("repository" ) or {}
704+ name_with_owner = repo_info .get ("nameWithOwner" )
705+ if name_with_owner :
706+ repos .add (name_with_owner )
707+ page_info = item_block .get ("pageInfo" ) or {}
708+ if not page_info .get ("hasNextPage" ):
709+ break
710+ cursor = page_info .get ("endCursor" )
711+ return sorted (repos )
712+
713+ def _infer_repo (self ) -> str :
714+ repos = self ._project_repos ()
715+ if len (repos ) == 1 :
716+ return repos [0 ]
717+ raise ConfigError ("-r/--repo must be specified" )
718+
589719 def _resolve_status (self , status : str ) -> Tuple [str , str ]:
590720 self ._ensure_project_loaded ()
591721 status_name = self ._resolve_status_name (status )
@@ -686,6 +816,8 @@ def _label_filter_hint(self) -> str:
686816 return f" (labels: { labels } )"
687817
688818 def _label_matches_owner (self , labels : Iterable [str ]) -> bool :
819+ if not self .owner_label :
820+ return False
689821 needle = self .owner_label .lower ()
690822 return any (label .lower () == needle for label in labels )
691823
0 commit comments