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.
Files changed (49) hide show
  1. project_kit/__init__.py +8 -0
  2. project_kit/__main__.py +5 -0
  3. project_kit/agents.py +81 -0
  4. project_kit/ai_attribution.py +69 -0
  5. project_kit/approval.py +36 -0
  6. project_kit/assets/hooks/commit-msg +14 -0
  7. project_kit/assets/project/workflow.yml +63 -0
  8. project_kit/assets/skills/pk-decide/SKILL.md +63 -0
  9. project_kit/assets/skills/pk-discover/SKILL.md +57 -0
  10. project_kit/assets/skills/pk-handoff/SKILL.md +43 -0
  11. project_kit/assets/skills/pk-plan/SKILL.md +55 -0
  12. project_kit/assets/skills/pk-provision/SKILL.md +45 -0
  13. project_kit/assets/skills/pk-start/SKILL.md +47 -0
  14. project_kit/assets/skills/pk-workflow/SKILL.md +51 -0
  15. project_kit/assets/templates/adr.md +32 -0
  16. project_kit/assets/templates/backlog.example.json +59 -0
  17. project_kit/assets/templates/brief.md +77 -0
  18. project_kit/assets/templates/constitution-input.md +78 -0
  19. project_kit/assets/templates/development-workflow.md +51 -0
  20. project_kit/assets/templates/github/ISSUE_TEMPLATE/adr.yml +21 -0
  21. project_kit/assets/templates/github/ISSUE_TEMPLATE/bug.yml +25 -0
  22. project_kit/assets/templates/github/ISSUE_TEMPLATE/config.yml +1 -0
  23. project_kit/assets/templates/github/ISSUE_TEMPLATE/epic.yml +15 -0
  24. project_kit/assets/templates/github/ISSUE_TEMPLATE/feature.yml +22 -0
  25. project_kit/assets/templates/github/pull_request_template.md +21 -0
  26. project_kit/assets/templates/interview.md +68 -0
  27. project_kit/backlog.py +312 -0
  28. project_kit/cli.py +286 -0
  29. project_kit/config.py +139 -0
  30. project_kit/doctor.py +187 -0
  31. project_kit/fsutil.py +27 -0
  32. project_kit/gh.py +152 -0
  33. project_kit/handoff.py +360 -0
  34. project_kit/help.py +152 -0
  35. project_kit/installer.py +100 -0
  36. project_kit/labels.py +17 -0
  37. project_kit/project_choice.py +127 -0
  38. project_kit/providers/__init__.py +1 -0
  39. project_kit/providers/github.py +329 -0
  40. project_kit/provision.py +260 -0
  41. project_kit/provision_issues.py +360 -0
  42. project_kit/state.py +115 -0
  43. project_kit/templating.py +23 -0
  44. project_kit/workflow.py +168 -0
  45. project_kit_cli-0.1.0.dist-info/METADATA +97 -0
  46. project_kit_cli-0.1.0.dist-info/RECORD +49 -0
  47. project_kit_cli-0.1.0.dist-info/WHEEL +4 -0
  48. project_kit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  49. 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)