Skip to content

Commit e0f0097

Browse files
committed
Add create command for project issues
1 parent 6d6e7f2 commit e0f0097

6 files changed

Lines changed: 189 additions & 8 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ project.release(2)
4646
# context-managed lease (auto-release on exit)
4747
with project.lease("Backlog") as issue:
4848
print(issue.number)
49+
50+
# create a new issue and add it to the project
51+
issue = project.create(
52+
title="Investigate latency spikes",
53+
description="See logs from Jan 24",
54+
status="Backlog",
55+
repo="yieldthought/gh-task", # optional if the project has a single repo
56+
label="bug",
57+
)
4958
```
5059

5160
## CLI
@@ -66,6 +75,10 @@ with project.lease("Backlog") as issue:
6675
# move and release
6776
gh-task move -p yieldthought/projects/3 -n my-name -i 2 -s "In review"
6877
gh-task release -p yieldthought/projects/3 -n my-name -i 2
78+
79+
# create a new issue and add it to the project (repo inferred if possible)
80+
gh-task create -p yieldthought/projects/3 -r yieldthought/gh-task -t "Fix retry logic" -d "Add jitter + cap"
81+
gh-task create -p yieldthought/projects/3 -t "Investigate latency spikes" -s Backlog --label bug
6982
```
7083

7184
## Label filtering

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "gh-task"
7-
version = "0.1.5"
7+
version = "0.1.6"
88
description = "Python + CLI helpers for GitHub Projects task ownership"
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/gh_task/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .errors import (
22
ApiError,
33
ConfigError,
4+
CreateError,
45
GhTaskError,
56
MoveError,
67
NotFoundError,
@@ -13,6 +14,7 @@
1314
__all__ = [
1415
"ApiError",
1516
"ConfigError",
17+
"CreateError",
1618
"GhTaskError",
1719
"Issue",
1820
"IssueLease",

src/gh_task/cli.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,40 @@ def main(argv: Optional[list[str]] = None) -> int:
8484
list_parser.add_argument("status", nargs="?", help="Status name to list issues for")
8585
list_parser.add_argument("-s", "--status-name", help="Status name to list issues for")
8686

87+
create_parser = subparsers.add_parser("create", help="Create an issue and add it to the project")
88+
create_parser.add_argument(
89+
"-p",
90+
"--project",
91+
required=True,
92+
help="Project reference (e.g. yieldthought/projects/3 or full URL)",
93+
)
94+
create_parser.add_argument("--token", help="GitHub token (defaults to GH_TOKEN/GITHUB_TOKEN or gh auth)")
95+
create_parser.add_argument("-r", "--repo", help="Repo name (owner/repo)")
96+
create_parser.add_argument("-t", "--title", required=True, help="Issue title")
97+
create_parser.add_argument("-d", "--description", help="Issue description/body")
98+
create_parser.add_argument("-s", "--status", default="Backlog", help="Status name (default: Backlog)")
99+
create_parser.add_argument(
100+
"-l",
101+
"--label",
102+
help="Label to add to the issue (created if missing)",
103+
)
104+
87105
args = parser.parse_args(argv)
88-
labels = _parse_label_filter(args.label)
89106

90107
try:
108+
if args.command == "create":
109+
project = Project(project=args.project, token=args.token)
110+
result = project.create(
111+
title=args.title,
112+
description=args.description,
113+
status=args.status,
114+
repo=args.repo,
115+
label=args.label,
116+
)
117+
print(f"{_format_issue(result)} -> {result.status}")
118+
return 0
119+
120+
labels = _parse_label_filter(args.label)
91121
project = Project(project=args.project, name=args.name, token=args.token, has_label=labels)
92122

93123
if args.command == "take":

src/gh_task/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ class MoveError(GhTaskError):
3333

3434
class ReleaseError(GhTaskError):
3535
"""Raised when a release operation fails."""
36+
37+
38+
class CreateError(GhTaskError):
39+
"""Raised when a create operation fails."""

src/gh_task/project.py

Lines changed: 138 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,16 @@
55
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
66
from 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+
)
918
from .github import GitHubClient
1019

1120

@@ -140,6 +149,14 @@
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+
143160
COMMENT_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

Comments
 (0)