project-kit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- project_kit/__init__.py +8 -0
- project_kit/__main__.py +5 -0
- project_kit/agents.py +81 -0
- project_kit/ai_attribution.py +69 -0
- project_kit/approval.py +36 -0
- project_kit/assets/hooks/commit-msg +14 -0
- project_kit/assets/project/workflow.yml +63 -0
- project_kit/assets/skills/pk-decide/SKILL.md +63 -0
- project_kit/assets/skills/pk-discover/SKILL.md +57 -0
- project_kit/assets/skills/pk-handoff/SKILL.md +43 -0
- project_kit/assets/skills/pk-plan/SKILL.md +55 -0
- project_kit/assets/skills/pk-provision/SKILL.md +45 -0
- project_kit/assets/skills/pk-start/SKILL.md +47 -0
- project_kit/assets/skills/pk-workflow/SKILL.md +51 -0
- project_kit/assets/templates/adr.md +32 -0
- project_kit/assets/templates/backlog.example.json +59 -0
- project_kit/assets/templates/brief.md +77 -0
- project_kit/assets/templates/constitution-input.md +78 -0
- project_kit/assets/templates/development-workflow.md +51 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/adr.yml +21 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/bug.yml +25 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/config.yml +1 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/epic.yml +15 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/feature.yml +22 -0
- project_kit/assets/templates/github/pull_request_template.md +21 -0
- project_kit/assets/templates/interview.md +68 -0
- project_kit/backlog.py +312 -0
- project_kit/cli.py +286 -0
- project_kit/config.py +139 -0
- project_kit/doctor.py +187 -0
- project_kit/fsutil.py +27 -0
- project_kit/gh.py +152 -0
- project_kit/handoff.py +360 -0
- project_kit/help.py +152 -0
- project_kit/installer.py +100 -0
- project_kit/labels.py +17 -0
- project_kit/project_choice.py +127 -0
- project_kit/providers/__init__.py +1 -0
- project_kit/providers/github.py +329 -0
- project_kit/provision.py +260 -0
- project_kit/provision_issues.py +360 -0
- project_kit/state.py +115 -0
- project_kit/templating.py +23 -0
- project_kit/workflow.py +168 -0
- project_kit_cli-0.1.0.dist-info/METADATA +97 -0
- project_kit_cli-0.1.0.dist-info/RECORD +49 -0
- project_kit_cli-0.1.0.dist-info/WHEEL +4 -0
- project_kit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- project_kit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Which GitHub Project does provisioning use?
|
|
2
|
+
|
|
3
|
+
Before creating anything, look for what already exists:
|
|
4
|
+
|
|
5
|
+
* the Project is already recorded in ``state.json``: use it, nothing to ask;
|
|
6
|
+
* ``--project`` was given: a number or the exact title of one of the owner's Projects, or a
|
|
7
|
+
title for a new one;
|
|
8
|
+
* otherwise look at the Projects linked to the repository (plus one named like the default),
|
|
9
|
+
and: none -> ask for a name and create it; one -> ask whether to use it; several -> list
|
|
10
|
+
them. Without a terminal (or with ``--yes``) one is used as it is and several are an error
|
|
11
|
+
that tells the caller to pass ``--project``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from project_kit.config import Config
|
|
18
|
+
from project_kit.providers.github import GitHubProvider, ProjectRef
|
|
19
|
+
from project_kit.state import State
|
|
20
|
+
|
|
21
|
+
_YES = ("", "y", "yes", "s", "si", "sí")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProjectChoiceError(ValueError):
|
|
25
|
+
"""The Project to use cannot be decided without the user."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Target:
|
|
30
|
+
title: str
|
|
31
|
+
number: int | None = None # None: it does not exist yet and will be created
|
|
32
|
+
detected: bool = False # chosen by looking at GitHub, not remembered from an earlier run
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def default_title(config: Config) -> str:
|
|
36
|
+
return f"{config.project_name} Roadmap"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stored_target(state: State, config: Config) -> Target | None:
|
|
40
|
+
number = state.get_id("project", "number")
|
|
41
|
+
if number is None:
|
|
42
|
+
return None
|
|
43
|
+
return Target(state.get_id("project", "title") or default_title(config), int(number))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _describe(project: ProjectRef) -> str:
|
|
47
|
+
return f"{project.title} (#{project.number}) {project.url}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _name_for_new(default: str, ask: Callable[[str], str]) -> Target:
|
|
51
|
+
name = ask(f"Name for the new Project [{default}]: ").strip() or default
|
|
52
|
+
return Target(name, None, detected=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _requested(requested: str, owned: list[ProjectRef], owner: str) -> Target:
|
|
56
|
+
text = requested.strip()
|
|
57
|
+
if text.isdigit():
|
|
58
|
+
found = next((p for p in owned if p.number == int(text)), None)
|
|
59
|
+
if found is None:
|
|
60
|
+
raise ProjectChoiceError(f"{owner} has no open Project number {text}.")
|
|
61
|
+
return Target(found.title, found.number, detected=True)
|
|
62
|
+
found = next((p for p in owned if p.title == text), None)
|
|
63
|
+
if found is not None:
|
|
64
|
+
return Target(found.title, found.number, detected=True)
|
|
65
|
+
return Target(text, None, detected=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_target(
|
|
69
|
+
provider: GitHubProvider,
|
|
70
|
+
owner: str,
|
|
71
|
+
config: Config,
|
|
72
|
+
state: State,
|
|
73
|
+
*,
|
|
74
|
+
requested: str | None,
|
|
75
|
+
interactive: bool,
|
|
76
|
+
assume_yes: bool,
|
|
77
|
+
ask: Callable[[str], str] | None = None,
|
|
78
|
+
out: Callable[[str], None] = print,
|
|
79
|
+
) -> Target:
|
|
80
|
+
stored = stored_target(state, config)
|
|
81
|
+
if stored and not requested:
|
|
82
|
+
return stored
|
|
83
|
+
|
|
84
|
+
owned = provider.list_projects(owner)
|
|
85
|
+
if requested:
|
|
86
|
+
return _requested(requested, owned, owner)
|
|
87
|
+
|
|
88
|
+
default = default_title(config)
|
|
89
|
+
candidates = list(provider.linked_projects())
|
|
90
|
+
linked = {p.number for p in candidates}
|
|
91
|
+
candidates += [p for p in owned if p.title == default and p.number not in linked]
|
|
92
|
+
ask = ask or input
|
|
93
|
+
can_ask = interactive and not assume_yes
|
|
94
|
+
|
|
95
|
+
if not candidates:
|
|
96
|
+
if can_ask:
|
|
97
|
+
out("No Project is linked to this repository yet.")
|
|
98
|
+
return _name_for_new(default, ask)
|
|
99
|
+
return Target(default, None, detected=True)
|
|
100
|
+
|
|
101
|
+
if len(candidates) == 1:
|
|
102
|
+
only = candidates[0]
|
|
103
|
+
if not can_ask:
|
|
104
|
+
return Target(only.title, only.number, detected=True)
|
|
105
|
+
answer = ask(f"Use the existing Project {_describe(only)}? [Y/n] ").strip().lower()
|
|
106
|
+
if answer in _YES:
|
|
107
|
+
return Target(only.title, only.number, detected=True)
|
|
108
|
+
return _name_for_new(default, ask)
|
|
109
|
+
|
|
110
|
+
listing = [f" {n}) {_describe(p)}" for n, p in enumerate(candidates, 1)]
|
|
111
|
+
if not can_ask:
|
|
112
|
+
raise ProjectChoiceError(
|
|
113
|
+
"Several Projects match this repository:\n"
|
|
114
|
+
+ "\n".join(listing)
|
|
115
|
+
+ "\nChoose one with --project <number or title>, or a new title to create one."
|
|
116
|
+
)
|
|
117
|
+
out("Several Projects are linked to this repository:")
|
|
118
|
+
for line in listing:
|
|
119
|
+
out(line)
|
|
120
|
+
out(f" {len(candidates) + 1}) Create a new one")
|
|
121
|
+
answer = ask("Choice [1]: ").strip() or "1"
|
|
122
|
+
if not answer.isdigit() or not 1 <= int(answer) <= len(candidates) + 1:
|
|
123
|
+
raise ProjectChoiceError(f"{answer!r} is not one of the options.")
|
|
124
|
+
if int(answer) == len(candidates) + 1:
|
|
125
|
+
return _name_for_new(default, ask)
|
|
126
|
+
chosen = candidates[int(answer) - 1]
|
|
127
|
+
return Target(chosen.title, chosen.number, detected=True)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Provider adapters. Each one drives a hosting service through its official CLI."""
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""GitHub provider: everything goes through the ``gh`` CLI (design decision D7)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from project_kit.gh import Gh, GhError
|
|
9
|
+
|
|
10
|
+
_PAGE = 100
|
|
11
|
+
_ISSUE_MARKER = re.compile(r"<!--\s*project-kit:id=([a-z0-9]+(?:-[a-z0-9]+)*)\s*-->")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class RepoInfo:
|
|
16
|
+
full_name: str
|
|
17
|
+
private: bool
|
|
18
|
+
default_branch: str
|
|
19
|
+
can_admin: bool
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def owner(self) -> str:
|
|
23
|
+
return self.full_name.split("/")[0]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Snapshot:
|
|
28
|
+
"""What exists in the repository right now."""
|
|
29
|
+
|
|
30
|
+
repo: RepoInfo
|
|
31
|
+
labels: set[str] = field(default_factory=set) # lower-cased: GitHub treats them as such
|
|
32
|
+
milestones: dict[str, int] = field(default_factory=dict) # title -> number
|
|
33
|
+
branches: dict[str, str] = field(default_factory=dict) # name -> commit sha
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ProjectRef:
|
|
38
|
+
number: int
|
|
39
|
+
id: str
|
|
40
|
+
url: str
|
|
41
|
+
title: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class SelectField:
|
|
46
|
+
"""A single-select Project field. ``options`` maps option name -> option id."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
name: str
|
|
50
|
+
options: dict[str, str] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class SelectOption:
|
|
55
|
+
name: str
|
|
56
|
+
color: str = "GRAY"
|
|
57
|
+
description: str = ""
|
|
58
|
+
id: str | None = None # present for options that already exist, so they are kept
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ProjectItem:
|
|
63
|
+
id: str
|
|
64
|
+
number: int | None # None for draft items
|
|
65
|
+
values: dict[str, str] = field(default_factory=dict) # lower-cased field name -> option
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class RemoteIssue:
|
|
70
|
+
number: int
|
|
71
|
+
title: str
|
|
72
|
+
body: str
|
|
73
|
+
url: str
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def local_id(self) -> str | None:
|
|
77
|
+
match = _ISSUE_MARKER.search(self.body or "")
|
|
78
|
+
return match.group(1) if match else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GitHubProvider:
|
|
82
|
+
def __init__(self, gh: Gh, repo: str | None = None) -> None:
|
|
83
|
+
self.gh = gh
|
|
84
|
+
self._repo = repo
|
|
85
|
+
|
|
86
|
+
# --- reading ---------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def repo_info(self) -> RepoInfo:
|
|
89
|
+
args = ["repo", "view"] + ([self._repo] if self._repo else [])
|
|
90
|
+
data = self.gh.json(
|
|
91
|
+
*args, "--json", "nameWithOwner,isPrivate,defaultBranchRef,viewerPermission"
|
|
92
|
+
)
|
|
93
|
+
info = RepoInfo(
|
|
94
|
+
full_name=data["nameWithOwner"],
|
|
95
|
+
private=bool(data["isPrivate"]),
|
|
96
|
+
default_branch=(data.get("defaultBranchRef") or {}).get("name", ""),
|
|
97
|
+
can_admin=data.get("viewerPermission") in ("ADMIN", "MAINTAIN"),
|
|
98
|
+
)
|
|
99
|
+
self._repo = info.full_name
|
|
100
|
+
return info
|
|
101
|
+
|
|
102
|
+
def _paged(self, path: str) -> list[dict[str, Any]]:
|
|
103
|
+
"""Collect every page of a list endpoint (``gh api --paginate`` concatenates JSON
|
|
104
|
+
arrays, which cannot be parsed reliably, so pages are requested one by one)."""
|
|
105
|
+
items: list[dict[str, Any]] = []
|
|
106
|
+
sep = "&" if "?" in path else "?"
|
|
107
|
+
page = 1
|
|
108
|
+
while True:
|
|
109
|
+
chunk = self.gh.json("api", f"{path}{sep}per_page={_PAGE}&page={page}")
|
|
110
|
+
items.extend(chunk)
|
|
111
|
+
if len(chunk) < _PAGE:
|
|
112
|
+
return items
|
|
113
|
+
page += 1
|
|
114
|
+
|
|
115
|
+
def snapshot(self) -> Snapshot:
|
|
116
|
+
info = self.repo_info()
|
|
117
|
+
repo = info.full_name
|
|
118
|
+
labels = self.gh.json("label", "list", "--repo", repo, "--limit", "1000", "--json", "name")
|
|
119
|
+
return Snapshot(
|
|
120
|
+
repo=info,
|
|
121
|
+
labels={label["name"].lower() for label in labels},
|
|
122
|
+
milestones={
|
|
123
|
+
m["title"]: m["number"] for m in self._paged(f"repos/{repo}/milestones?state=all")
|
|
124
|
+
},
|
|
125
|
+
branches={b["name"]: b["commit"]["sha"] for b in self._paged(f"repos/{repo}/branches")},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def branch_protection_available(self) -> bool | None:
|
|
129
|
+
"""Probe whether this repository can have rulesets/branch protection.
|
|
130
|
+
|
|
131
|
+
GitHub answers 403 "Upgrade to GitHub Pro or make this repository public" on
|
|
132
|
+
private repositories of the free plan. ``None`` means the probe was inconclusive.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
self.gh.run("api", f"repos/{self._repo}/rulesets")
|
|
136
|
+
except GhError as exc:
|
|
137
|
+
return False if "upgrade to github" in exc.stderr.lower() else None
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
# --- writing: labels, milestones, branches ---------------------------------------
|
|
141
|
+
|
|
142
|
+
def create_label(self, name: str, color: str, description: str) -> None:
|
|
143
|
+
self.gh.run(
|
|
144
|
+
"label", "create", name,
|
|
145
|
+
"--repo", self._repo, "--color", color, "--description", description,
|
|
146
|
+
) # fmt: skip
|
|
147
|
+
|
|
148
|
+
def create_milestone(self, title: str, description: str) -> int:
|
|
149
|
+
data = self.gh.json(
|
|
150
|
+
"api", "-X", "POST", f"repos/{self._repo}/milestones",
|
|
151
|
+
"-f", f"title={title}", "-f", f"description={description}",
|
|
152
|
+
) # fmt: skip
|
|
153
|
+
return int(data["number"])
|
|
154
|
+
|
|
155
|
+
def create_branch(self, name: str, sha: str) -> None:
|
|
156
|
+
self.gh.run(
|
|
157
|
+
"api", "-X", "POST", f"repos/{self._repo}/git/refs",
|
|
158
|
+
"-f", f"ref=refs/heads/{name}", "-f", f"sha={sha}",
|
|
159
|
+
) # fmt: skip
|
|
160
|
+
|
|
161
|
+
# --- issues ----------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def list_issues(self) -> list[RemoteIssue]:
|
|
164
|
+
rows = self.gh.json(
|
|
165
|
+
"issue", "list", "--repo", self._repo, "--state", "all", "--limit", "1000",
|
|
166
|
+
"--json", "number,title,body,url",
|
|
167
|
+
) # fmt: skip
|
|
168
|
+
return [RemoteIssue(r["number"], r["title"], r.get("body") or "", r["url"]) for r in rows]
|
|
169
|
+
|
|
170
|
+
def create_issue(self, title: str, body: str, milestone: str, labels: list[str]) -> RemoteIssue:
|
|
171
|
+
args = ["issue", "create", "--repo", self._repo, "--title", title, "--body-file", "-"]
|
|
172
|
+
args += ["--milestone", milestone]
|
|
173
|
+
for label in labels:
|
|
174
|
+
args += ["--label", label]
|
|
175
|
+
url = self.gh.run(*args, input=body).strip().splitlines()[-1]
|
|
176
|
+
return RemoteIssue(int(url.rstrip("/").rsplit("/", 1)[-1]), title, body, url)
|
|
177
|
+
|
|
178
|
+
def update_issue_body(self, number: int, body: str) -> None:
|
|
179
|
+
self.gh.run(
|
|
180
|
+
"issue", "edit", str(number), "--repo", self._repo, "--body-file", "-", input=body
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# --- the Project -----------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _graphql(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
|
|
186
|
+
data = self.gh.json(
|
|
187
|
+
"api", "graphql", "--input", "-",
|
|
188
|
+
input=json.dumps({"query": query, "variables": variables}),
|
|
189
|
+
) # fmt: skip
|
|
190
|
+
if data.get("errors"):
|
|
191
|
+
raise GhError(f"GraphQL error: {data['errors'][0].get('message', data['errors'])}")
|
|
192
|
+
return data["data"]
|
|
193
|
+
|
|
194
|
+
def list_projects(self, owner: str) -> list[ProjectRef]:
|
|
195
|
+
"""The owner's open Projects."""
|
|
196
|
+
data = self.gh.json(
|
|
197
|
+
"project", "list", "--owner", owner, "--limit", "200", "--format", "json"
|
|
198
|
+
)
|
|
199
|
+
return [
|
|
200
|
+
ProjectRef(p["number"], p["id"], p["url"], p["title"])
|
|
201
|
+
for p in data["projects"]
|
|
202
|
+
if not p.get("closed")
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
def linked_projects(self) -> list[ProjectRef]:
|
|
206
|
+
"""The open Projects already linked to this repository."""
|
|
207
|
+
owner, _, name = self._repo.partition("/")
|
|
208
|
+
data = self._graphql(
|
|
209
|
+
"query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) {"
|
|
210
|
+
" projectsV2(first: 50) { nodes { number id url title closed } } } }",
|
|
211
|
+
{"owner": owner, "name": name},
|
|
212
|
+
)
|
|
213
|
+
nodes = (data.get("repository") or {}).get("projectsV2", {}).get("nodes") or []
|
|
214
|
+
return [
|
|
215
|
+
ProjectRef(n["number"], n["id"], n["url"], n["title"])
|
|
216
|
+
for n in nodes
|
|
217
|
+
if n and not n.get("closed")
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
def find_project(self, owner: str, title: str, number: int | None = None) -> ProjectRef | None:
|
|
221
|
+
"""By number when given (a Project chosen earlier), else by exact title."""
|
|
222
|
+
for p in self.list_projects(owner):
|
|
223
|
+
if p.number == number if number is not None else p.title == title:
|
|
224
|
+
return p
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def create_project(self, owner: str, title: str) -> ProjectRef:
|
|
228
|
+
p = self.gh.json(
|
|
229
|
+
"project", "create", "--owner", owner, "--title", title, "--format", "json"
|
|
230
|
+
)
|
|
231
|
+
return ProjectRef(p["number"], p["id"], p["url"])
|
|
232
|
+
|
|
233
|
+
def link_project(self, owner: str, project: ProjectRef) -> None:
|
|
234
|
+
"""Link the Project to the repository (repeating it is harmless)."""
|
|
235
|
+
self.gh.run("project", "link", str(project.number), "--owner", owner, "--repo", self._repo)
|
|
236
|
+
|
|
237
|
+
def project_fields(self, owner: str, project: ProjectRef) -> dict[str, SelectField]:
|
|
238
|
+
data = self.gh.json(
|
|
239
|
+
"project", "field-list", str(project.number),
|
|
240
|
+
"--owner", owner, "--limit", "100", "--format", "json",
|
|
241
|
+
) # fmt: skip
|
|
242
|
+
return {
|
|
243
|
+
f["name"]: SelectField(f["id"], f["name"], {o["name"]: o["id"] for o in f["options"]})
|
|
244
|
+
for f in data["fields"]
|
|
245
|
+
if "options" in f
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def field_options(self, field_id: str) -> list[SelectOption]:
|
|
249
|
+
"""Options with their colour and description, which ``field-list`` does not return."""
|
|
250
|
+
data = self._graphql(
|
|
251
|
+
"query($id:ID!){node(id:$id){... on ProjectV2SingleSelectField"
|
|
252
|
+
"{options{id name color description}}}}",
|
|
253
|
+
{"id": field_id},
|
|
254
|
+
)
|
|
255
|
+
return [
|
|
256
|
+
SelectOption(o["name"], o["color"], o["description"] or "", o["id"])
|
|
257
|
+
for o in data["node"]["options"]
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _option_input(options: list[SelectOption]) -> list[dict[str, str]]:
|
|
262
|
+
rows = []
|
|
263
|
+
for o in options:
|
|
264
|
+
row = {"name": o.name, "color": o.color, "description": o.description}
|
|
265
|
+
if o.id:
|
|
266
|
+
row["id"] = o.id
|
|
267
|
+
rows.append(row)
|
|
268
|
+
return rows
|
|
269
|
+
|
|
270
|
+
def create_select_field(
|
|
271
|
+
self, project: ProjectRef, name: str, options: list[SelectOption]
|
|
272
|
+
) -> None:
|
|
273
|
+
self._graphql(
|
|
274
|
+
"mutation($p:ID!,$n:String!,$o:[ProjectV2SingleSelectFieldOptionInput!]){"
|
|
275
|
+
"createProjectV2Field(input:{projectId:$p,dataType:SINGLE_SELECT,name:$n,"
|
|
276
|
+
"singleSelectOptions:$o}){projectV2Field{... on ProjectV2SingleSelectField{id}}}}",
|
|
277
|
+
{"p": project.id, "n": name, "o": self._option_input(options)},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def set_select_options(self, field_id: str, options: list[SelectOption]) -> None:
|
|
281
|
+
"""Replace a field's options. Options passed with an ``id`` keep their identity."""
|
|
282
|
+
self._graphql(
|
|
283
|
+
"mutation($f:ID!,$o:[ProjectV2SingleSelectFieldOptionInput!]!){"
|
|
284
|
+
"updateProjectV2Field(input:{fieldId:$f,singleSelectOptions:$o})"
|
|
285
|
+
"{projectV2Field{... on ProjectV2SingleSelectField{id}}}}",
|
|
286
|
+
{"f": field_id, "o": self._option_input(options)},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def project_items(self, owner: str, project: ProjectRef) -> list[ProjectItem]:
|
|
290
|
+
data = self.gh.json(
|
|
291
|
+
"project", "item-list", str(project.number),
|
|
292
|
+
"--owner", owner, "--limit", "2000", "--format", "json",
|
|
293
|
+
) # fmt: skip
|
|
294
|
+
fields = ("status", "phase", "kind", "priority", "size")
|
|
295
|
+
return [
|
|
296
|
+
ProjectItem(
|
|
297
|
+
id=i["id"],
|
|
298
|
+
number=(i.get("content") or {}).get("number"),
|
|
299
|
+
values={k: i[k] for k in fields if i.get(k)},
|
|
300
|
+
)
|
|
301
|
+
for i in data["items"]
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
def add_item(self, owner: str, project: ProjectRef, issue_url: str) -> str:
|
|
305
|
+
"""Add an issue to the Project; if it is already there this returns the same item."""
|
|
306
|
+
data = self.gh.json(
|
|
307
|
+
"project", "item-add", str(project.number),
|
|
308
|
+
"--owner", owner, "--url", issue_url, "--format", "json",
|
|
309
|
+
) # fmt: skip
|
|
310
|
+
return data["id"]
|
|
311
|
+
|
|
312
|
+
def set_item_values(self, project: ProjectRef, item_id: str, values: dict[str, str]) -> None:
|
|
313
|
+
"""Set several single-select fields of one item in a single request.
|
|
314
|
+
|
|
315
|
+
``values`` maps field id -> option id.
|
|
316
|
+
"""
|
|
317
|
+
if not values:
|
|
318
|
+
return
|
|
319
|
+
params = ["$p:ID!", "$i:ID!"]
|
|
320
|
+
calls = []
|
|
321
|
+
variables: dict[str, Any] = {"p": project.id, "i": item_id}
|
|
322
|
+
for n, (field_id, option_id) in enumerate(values.items()):
|
|
323
|
+
params += [f"$f{n}:ID!", f"$o{n}:String!"]
|
|
324
|
+
variables |= {f"f{n}": field_id, f"o{n}": option_id}
|
|
325
|
+
calls.append(
|
|
326
|
+
f"a{n}:updateProjectV2ItemFieldValue(input:{{projectId:$p,itemId:$i,"
|
|
327
|
+
f"fieldId:$f{n},value:{{singleSelectOptionId:$o{n}}}}}){{projectV2Item{{id}}}}"
|
|
328
|
+
)
|
|
329
|
+
self._graphql(f"mutation({','.join(params)}){{{' '.join(calls)}}}", variables)
|