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
project_kit/provision.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""``project-kit provision``, part 1: milestones, labels and permanent branches on GitHub.
|
|
2
|
+
|
|
3
|
+
The flow is plan -> show -> confirm -> apply. Planning only reads from GitHub, and only
|
|
4
|
+
*missing* things are created: anything that already exists is left exactly as it is, so
|
|
5
|
+
running it again changes nothing and never overwrites manual edits.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from project_kit.backlog import Backlog, load_backlog
|
|
13
|
+
from project_kit.config import Config, ConfigError, load_config
|
|
14
|
+
from project_kit.gh import GhError
|
|
15
|
+
from project_kit.labels import PRIORITY_LABELS, TYPE_LABELS
|
|
16
|
+
from project_kit.project_choice import ProjectChoiceError, resolve_target
|
|
17
|
+
from project_kit.providers.github import GitHubProvider, Snapshot
|
|
18
|
+
from project_kit.provision_issues import (
|
|
19
|
+
IssuePlan,
|
|
20
|
+
apply_issues,
|
|
21
|
+
plan_issues,
|
|
22
|
+
read_world,
|
|
23
|
+
record_existing,
|
|
24
|
+
)
|
|
25
|
+
from project_kit.state import State, load_state, save_state
|
|
26
|
+
|
|
27
|
+
_KIND_ORDER = {"milestone": 0, "label": 1, "branch": 2}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Action:
|
|
32
|
+
"""One thing to create. ``color``/``sha`` are only meaningful for labels/branches."""
|
|
33
|
+
|
|
34
|
+
kind: str # "milestone" | "label" | "branch"
|
|
35
|
+
name: str
|
|
36
|
+
description: str = ""
|
|
37
|
+
color: str = ""
|
|
38
|
+
sha: str = ""
|
|
39
|
+
phase_id: str = "" # milestones: the backlog phase this one belongs to
|
|
40
|
+
|
|
41
|
+
def describe(self) -> str:
|
|
42
|
+
if self.kind == "label":
|
|
43
|
+
return f"+ label {self.name} (#{self.color})"
|
|
44
|
+
if self.kind == "branch":
|
|
45
|
+
return f"+ branch {self.name} (from {self.sha[:7]})"
|
|
46
|
+
return f"+ milestone {self.name}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Plan:
|
|
51
|
+
actions: list[Action] = field(default_factory=list)
|
|
52
|
+
existing: dict[str, int] = field(default_factory=dict) # kind -> how many already exist
|
|
53
|
+
milestone_ids: dict[str, int] = field(default_factory=dict) # phase id -> number, existing
|
|
54
|
+
problems: list[str] = field(default_factory=list) # blocking: nothing can be applied
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _desired_labels(backlog: Backlog) -> list[tuple[str, str, str]]:
|
|
58
|
+
wanted: dict[str, tuple[str, str, str]] = {}
|
|
59
|
+
for issue in backlog.issues:
|
|
60
|
+
for label in (TYPE_LABELS[issue.type], PRIORITY_LABELS[issue.priority]):
|
|
61
|
+
wanted.setdefault(label[0].lower(), label)
|
|
62
|
+
for label in backlog.labels:
|
|
63
|
+
wanted.setdefault(label.name.lower(), (label.name, label.color, label.description))
|
|
64
|
+
return list(wanted.values())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_plan(backlog: Backlog, config: Config, snap: Snapshot) -> Plan:
|
|
68
|
+
plan = Plan()
|
|
69
|
+
existing = {"milestone": 0, "label": 0, "branch": 0}
|
|
70
|
+
|
|
71
|
+
for phase in backlog.phases:
|
|
72
|
+
number = snap.milestones.get(phase.title)
|
|
73
|
+
if number is None:
|
|
74
|
+
plan.actions.append(
|
|
75
|
+
Action("milestone", phase.title, phase.description, phase_id=phase.id)
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
existing["milestone"] += 1
|
|
79
|
+
plan.milestone_ids[phase.id] = number
|
|
80
|
+
|
|
81
|
+
for name, color, description in _desired_labels(backlog):
|
|
82
|
+
if name.lower() in snap.labels:
|
|
83
|
+
existing["label"] += 1
|
|
84
|
+
else:
|
|
85
|
+
plan.actions.append(Action("label", name, description, color))
|
|
86
|
+
|
|
87
|
+
main = config.branches["main"]
|
|
88
|
+
if snap.repo.default_branch and snap.repo.default_branch != main:
|
|
89
|
+
plan.problems.append(
|
|
90
|
+
f"config.yml says the main branch is {main!r} but the repository's default "
|
|
91
|
+
f"branch is {snap.repo.default_branch!r}. Fix branches.main in config.yml."
|
|
92
|
+
)
|
|
93
|
+
main_sha = snap.branches.get(main)
|
|
94
|
+
if main_sha is None:
|
|
95
|
+
plan.problems.append(
|
|
96
|
+
f"Branch {main!r} does not exist in {snap.repo.full_name}. Push an initial "
|
|
97
|
+
"commit first: new branches are created from it."
|
|
98
|
+
)
|
|
99
|
+
for key in ("release", "develop"):
|
|
100
|
+
name = config.branches[key]
|
|
101
|
+
if name in snap.branches:
|
|
102
|
+
existing["branch"] += 1
|
|
103
|
+
elif main_sha:
|
|
104
|
+
plan.actions.append(Action("branch", name, sha=main_sha))
|
|
105
|
+
if main_sha:
|
|
106
|
+
existing["branch"] += 1
|
|
107
|
+
|
|
108
|
+
plan.existing = existing
|
|
109
|
+
plan.actions.sort(key=lambda a: _KIND_ORDER[a.kind])
|
|
110
|
+
return plan
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def protection_note(available: bool | None) -> str:
|
|
114
|
+
if available is False:
|
|
115
|
+
return (
|
|
116
|
+
"Branch protection: NOT available (private repository on a plan without it). "
|
|
117
|
+
"Not attempted. Enforce the branch rules by convention and write that down in "
|
|
118
|
+
"your workflow rules; protect the branches once the repository is public or "
|
|
119
|
+
"the plan allows it."
|
|
120
|
+
)
|
|
121
|
+
if available:
|
|
122
|
+
return (
|
|
123
|
+
"Branch protection: available, but project-kit does not configure it yet. "
|
|
124
|
+
"Set it up by hand."
|
|
125
|
+
)
|
|
126
|
+
return "Branch protection: could not be determined. Not attempted."
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_plan(
|
|
130
|
+
plan: Plan, repo: str, protection: bool | None, issues: IssuePlan | None = None
|
|
131
|
+
) -> str:
|
|
132
|
+
issues = issues or IssuePlan()
|
|
133
|
+
lines = [f"Repository: {repo}", ""]
|
|
134
|
+
if plan.actions:
|
|
135
|
+
lines += ["To create:"] + [f" {a.describe()}" for a in plan.actions]
|
|
136
|
+
else:
|
|
137
|
+
lines.append("Nothing to create.")
|
|
138
|
+
have = ", ".join(f"{n} {kind}(s)" for kind, n in plan.existing.items() if n)
|
|
139
|
+
if have:
|
|
140
|
+
lines += ["", f"Already present, left untouched: {have}."]
|
|
141
|
+
if issues.lines:
|
|
142
|
+
lines += ["", "Project and issues:"] + [f" {line}" for line in issues.lines]
|
|
143
|
+
for note in issues.notes:
|
|
144
|
+
lines += ["", f"Note: {note}"]
|
|
145
|
+
lines += ["", protection_note(protection)]
|
|
146
|
+
for problem in plan.problems:
|
|
147
|
+
lines += ["", f"BLOCKING: {problem}"]
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _record_milestones(state: State, ids: dict[str, int]) -> bool:
|
|
152
|
+
changed = False
|
|
153
|
+
for phase_id, number in ids.items():
|
|
154
|
+
if state.get_id("milestones", phase_id) != str(number):
|
|
155
|
+
state.set_id("milestones", phase_id, str(number))
|
|
156
|
+
changed = True
|
|
157
|
+
return changed
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def apply_plan(root: Path, provider: GitHubProvider, plan: Plan, state: State) -> None:
|
|
161
|
+
"""Execute the plan. Progress is saved as it goes, so a failure can be resumed."""
|
|
162
|
+
for action in plan.actions:
|
|
163
|
+
if action.kind == "milestone":
|
|
164
|
+
number = provider.create_milestone(action.name, action.description)
|
|
165
|
+
state.set_id("milestones", action.phase_id, str(number))
|
|
166
|
+
save_state(root, state)
|
|
167
|
+
elif action.kind == "label":
|
|
168
|
+
provider.create_label(action.name, action.color, action.description)
|
|
169
|
+
else:
|
|
170
|
+
provider.create_branch(action.name, action.sha)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _describe_target(target, world) -> str:
|
|
174
|
+
if world.project is None:
|
|
175
|
+
return f"Project: {target.title} (new)"
|
|
176
|
+
return f"Project: {target.title} (#{world.project.number}, already exists)"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _mark_provisioned(root: Path, state: State) -> None:
|
|
180
|
+
"""Save the state and record this phase as done (it has no approval prompt of its own)."""
|
|
181
|
+
if not state.is_approved("provision"):
|
|
182
|
+
state.approve("provision")
|
|
183
|
+
save_state(root, state)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_provision(
|
|
187
|
+
root: Path,
|
|
188
|
+
provider: GitHubProvider,
|
|
189
|
+
*,
|
|
190
|
+
dry_run: bool,
|
|
191
|
+
assume_yes: bool,
|
|
192
|
+
confirm: Callable[[str], bool],
|
|
193
|
+
out: Callable[[str], None] = print,
|
|
194
|
+
project: str | None = None,
|
|
195
|
+
interactive: bool = False,
|
|
196
|
+
ask: Callable[[str], str] | None = None,
|
|
197
|
+
) -> int:
|
|
198
|
+
"""Returns the process exit code."""
|
|
199
|
+
config = load_config(root)
|
|
200
|
+
if config.provider != "github":
|
|
201
|
+
raise ConfigError(f"Provider {config.provider!r} is not supported yet; use 'github'.")
|
|
202
|
+
backlog = load_backlog(root)
|
|
203
|
+
state = load_state(root)
|
|
204
|
+
|
|
205
|
+
if not dry_run and not state.is_approved("plan"):
|
|
206
|
+
out("The plan is not approved yet. Run the pk-plan skill, or `project-kit approve plan`.")
|
|
207
|
+
return 1
|
|
208
|
+
|
|
209
|
+
snap = provider.snapshot()
|
|
210
|
+
protection = provider.branch_protection_available()
|
|
211
|
+
plan = build_plan(backlog, config, snap)
|
|
212
|
+
owner = snap.repo.owner
|
|
213
|
+
try:
|
|
214
|
+
target = resolve_target(
|
|
215
|
+
provider, owner, config, state,
|
|
216
|
+
requested=project, interactive=interactive, assume_yes=assume_yes, ask=ask, out=out,
|
|
217
|
+
) # fmt: skip
|
|
218
|
+
except ProjectChoiceError as exc:
|
|
219
|
+
out(str(exc))
|
|
220
|
+
return 1
|
|
221
|
+
world = read_world(provider, owner, target)
|
|
222
|
+
if target.detected:
|
|
223
|
+
out(_describe_target(target, world))
|
|
224
|
+
issue_plan = plan_issues(backlog, target, world, state)
|
|
225
|
+
out(render_plan(plan, snap.repo.full_name, protection, issue_plan))
|
|
226
|
+
|
|
227
|
+
if plan.problems:
|
|
228
|
+
return 1
|
|
229
|
+
if dry_run:
|
|
230
|
+
out("\nDry run: nothing was changed.")
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
total = len(plan.actions) + len(issue_plan.lines)
|
|
234
|
+
if not total:
|
|
235
|
+
# Nothing to change on GitHub, but a lost or stale state can still be rebuilt.
|
|
236
|
+
_record_milestones(state, plan.milestone_ids)
|
|
237
|
+
record_existing(backlog, world, state)
|
|
238
|
+
_mark_provisioned(root, state)
|
|
239
|
+
out("\nNothing to do.")
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
if not assume_yes and not confirm(f"\nApply these {total} change(s) in {snap.repo.full_name}?"):
|
|
243
|
+
out("Aborted. Nothing was changed.")
|
|
244
|
+
return 1
|
|
245
|
+
|
|
246
|
+
_record_milestones(state, plan.milestone_ids)
|
|
247
|
+
try:
|
|
248
|
+
apply_plan(root, provider, plan, state)
|
|
249
|
+
notes = apply_issues(
|
|
250
|
+
provider, owner, backlog, target, state, lambda: save_state(root, state)
|
|
251
|
+
)
|
|
252
|
+
except GhError as exc:
|
|
253
|
+
save_state(root, state)
|
|
254
|
+
out(f"\nStopped: {exc}\nProgress was saved; run the command again to resume.")
|
|
255
|
+
return 1
|
|
256
|
+
_mark_provisioned(root, state)
|
|
257
|
+
for note in notes:
|
|
258
|
+
out(f"\nNote: {note}")
|
|
259
|
+
out(f"\nDone: {total} change(s) applied.")
|
|
260
|
+
return 0
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""``project-kit provision``, part 2: the GitHub Project and the backlog issues.
|
|
2
|
+
|
|
3
|
+
Both planning and applying start from a fresh read of GitHub (``read_world``) and never
|
|
4
|
+
trust a stored plan, so a run that died half way is simply continued by running it again.
|
|
5
|
+
|
|
6
|
+
Rules that keep it safe to repeat:
|
|
7
|
+
|
|
8
|
+
* An issue is identified by a hidden marker in its body (``project-kit:id=<local id>``) in
|
|
9
|
+
addition to ``state.json``, so losing the state does not duplicate issues.
|
|
10
|
+
* Existing issues are never rewritten, except the *child issues* block of an epic, which
|
|
11
|
+
project-kit owns (ticked boxes are preserved).
|
|
12
|
+
* Project fields are only filled when empty. Status in particular is set once, to Backlog,
|
|
13
|
+
so cards the user has moved are never moved back.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from project_kit.backlog import Backlog, Issue
|
|
21
|
+
from project_kit.labels import PRIORITY_LABELS, TYPE_LABELS
|
|
22
|
+
from project_kit.project_choice import Target
|
|
23
|
+
from project_kit.providers.github import (
|
|
24
|
+
GitHubProvider,
|
|
25
|
+
ProjectItem,
|
|
26
|
+
ProjectRef,
|
|
27
|
+
RemoteIssue,
|
|
28
|
+
SelectField,
|
|
29
|
+
SelectOption,
|
|
30
|
+
)
|
|
31
|
+
from project_kit.state import State
|
|
32
|
+
|
|
33
|
+
STATUS_OPTIONS = [
|
|
34
|
+
SelectOption("Backlog", "GRAY", "Not prioritised yet"),
|
|
35
|
+
SelectOption("Ready", "BLUE", "Ready to start"),
|
|
36
|
+
SelectOption("In progress", "YELLOW", "Being worked on"),
|
|
37
|
+
SelectOption("In review", "ORANGE", "Pull request open"),
|
|
38
|
+
SelectOption("Done", "GREEN", "Merged"),
|
|
39
|
+
]
|
|
40
|
+
# What GitHub puts in a brand-new Project. Only in this exact shape (and with no items yet)
|
|
41
|
+
# is the Status field safe to replace: replacing it resets the status of existing items.
|
|
42
|
+
_DEFAULT_STATUS = {"Todo", "In Progress", "Done"}
|
|
43
|
+
|
|
44
|
+
KIND_OF_TYPE = {
|
|
45
|
+
"epic": "Epic",
|
|
46
|
+
"feature": "Feature",
|
|
47
|
+
"adr": "ADR",
|
|
48
|
+
"chore": "Chore",
|
|
49
|
+
"bug": "Bug",
|
|
50
|
+
"docs": "Docs",
|
|
51
|
+
}
|
|
52
|
+
_STATIC_FIELDS = {
|
|
53
|
+
"Kind": list(KIND_OF_TYPE.values()),
|
|
54
|
+
"Priority": ["P0", "P1", "P2", "P3"],
|
|
55
|
+
"Size": ["XS", "S", "M", "L", "XL"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_CHILDREN_RE = re.compile(
|
|
59
|
+
r"<!-- project-kit:children:start -->.*?<!-- project-kit:children:end -->", re.DOTALL
|
|
60
|
+
)
|
|
61
|
+
_MARKER_RE = re.compile(r"<!--\s*project-kit:id=[a-z0-9-]+\s*-->")
|
|
62
|
+
_CHILD_LINE = re.compile(r"^- \[([ xX])\] #(\d+)", re.MULTILINE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- issue bodies ------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_body(issue: Issue, parent_number: int | None) -> str:
|
|
69
|
+
parts = []
|
|
70
|
+
if parent_number:
|
|
71
|
+
parts.append(f"Part of #{parent_number}.")
|
|
72
|
+
if issue.body.strip():
|
|
73
|
+
parts.append(issue.body.strip())
|
|
74
|
+
if issue.acceptance:
|
|
75
|
+
checklist = "\n".join(f"- [ ] {item}" for item in issue.acceptance)
|
|
76
|
+
parts.append(f"## Acceptance criteria\n{checklist}")
|
|
77
|
+
parts.append(f"<!-- project-kit:id={issue.id} -->")
|
|
78
|
+
return "\n\n".join(parts) + "\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def with_children_block(body: str, numbers: list[int]) -> str:
|
|
82
|
+
"""Return ``body`` with the epic's child list set to ``numbers``.
|
|
83
|
+
|
|
84
|
+
Boxes the user already ticked stay ticked. The block replaces the previous one, or goes
|
|
85
|
+
just before the identifying marker (or at the end) the first time.
|
|
86
|
+
"""
|
|
87
|
+
ticked = {int(n) for mark, n in _CHILD_LINE.findall(body) if mark in "xX"}
|
|
88
|
+
lines = "\n".join(f"- [{'x' if n in ticked else ' '}] #{n}" for n in numbers)
|
|
89
|
+
block = (
|
|
90
|
+
"<!-- project-kit:children:start -->\n"
|
|
91
|
+
f"## Child issues\n{lines}\n"
|
|
92
|
+
"<!-- project-kit:children:end -->"
|
|
93
|
+
)
|
|
94
|
+
if _CHILDREN_RE.search(body):
|
|
95
|
+
return _CHILDREN_RE.sub(lambda _: block, body, count=1)
|
|
96
|
+
marker = _MARKER_RE.search(body)
|
|
97
|
+
if marker:
|
|
98
|
+
return body[: marker.start()] + block + "\n\n" + body[marker.start() :]
|
|
99
|
+
return body.rstrip("\n") + "\n\n" + block + "\n"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def issue_labels(issue: Issue) -> list[str]:
|
|
103
|
+
return [TYPE_LABELS[issue.type][0], PRIORITY_LABELS[issue.priority][0], *issue.labels]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- reading the current state -----------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class World:
|
|
111
|
+
project: ProjectRef | None
|
|
112
|
+
fields: dict[str, SelectField] = field(default_factory=dict)
|
|
113
|
+
items: list[ProjectItem] = field(default_factory=list)
|
|
114
|
+
issues: list[RemoteIssue] = field(default_factory=list)
|
|
115
|
+
|
|
116
|
+
def remote_by_local_id(self, state: State) -> dict[str, RemoteIssue]:
|
|
117
|
+
"""Existing issues keyed by backlog id: by marker, or by the number in the state."""
|
|
118
|
+
by_number = {i.number: i for i in self.issues}
|
|
119
|
+
found = {i.local_id: i for i in self.issues if i.local_id}
|
|
120
|
+
for local_id, number in state.provider_ids.get("issues", {}).items():
|
|
121
|
+
if local_id not in found and int(number) in by_number:
|
|
122
|
+
found[local_id] = by_number[int(number)]
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_world(provider: GitHubProvider, owner: str, target: Target) -> World:
|
|
127
|
+
project = provider.find_project(owner, target.title, target.number)
|
|
128
|
+
world = World(project, issues=provider.list_issues())
|
|
129
|
+
if project:
|
|
130
|
+
world.fields = provider.project_fields(owner, project)
|
|
131
|
+
world.items = provider.project_items(owner, project)
|
|
132
|
+
return world
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _wanted_fields(backlog: Backlog) -> dict[str, list[str]]:
|
|
136
|
+
return {"Phase": [p.title for p in backlog.phases], **_STATIC_FIELDS}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _item_values(backlog: Backlog, issue: Issue) -> dict[str, tuple[str, str]]:
|
|
140
|
+
"""The values project-kit wants on an item: lower-cased field -> (field name, option)."""
|
|
141
|
+
phase = next(p.title for p in backlog.phases if p.id == issue.phase)
|
|
142
|
+
return {
|
|
143
|
+
"status": ("Status", "Backlog"),
|
|
144
|
+
"phase": ("Phase", phase),
|
|
145
|
+
"kind": ("Kind", KIND_OF_TYPE[issue.type]),
|
|
146
|
+
"priority": ("Priority", issue.priority),
|
|
147
|
+
"size": ("Size", issue.size),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _epic_numbers(backlog: Backlog, epic: Issue, remote: dict[str, RemoteIssue]) -> list[int]:
|
|
152
|
+
return [remote[c.id].number for c in backlog.children_of(epic.id) if c.id in remote]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _status_is_replaceable(world: World) -> bool:
|
|
156
|
+
status = world.fields.get("Status")
|
|
157
|
+
return bool(status) and set(status.options) == _DEFAULT_STATUS and not world.items
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --- planning ----------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class IssuePlan:
|
|
165
|
+
lines: list[str] = field(default_factory=list)
|
|
166
|
+
notes: list[str] = field(default_factory=list)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def plan_issues(backlog: Backlog, target: Target, world: World, state: State) -> IssuePlan:
|
|
170
|
+
plan = IssuePlan()
|
|
171
|
+
remote = world.remote_by_local_id(state)
|
|
172
|
+
|
|
173
|
+
if world.project is None:
|
|
174
|
+
plan.lines.append(f"+ project {target.title} (linked to the repository)")
|
|
175
|
+
else:
|
|
176
|
+
for name, options in _wanted_fields(backlog).items():
|
|
177
|
+
current = world.fields.get(name)
|
|
178
|
+
if current is None:
|
|
179
|
+
plan.lines.append(f"+ field {name} ({len(options)} option(s))")
|
|
180
|
+
elif missing := [o for o in options if o not in current.options]:
|
|
181
|
+
plan.lines.append(f"~ field {name}: add {', '.join(missing)}")
|
|
182
|
+
if _status_is_replaceable(world):
|
|
183
|
+
plan.lines.append("~ field Status: Backlog, Ready, In progress, In review, Done")
|
|
184
|
+
elif "Status" in world.fields:
|
|
185
|
+
missing = [
|
|
186
|
+
o.name for o in STATUS_OPTIONS if o.name not in world.fields["Status"].options
|
|
187
|
+
]
|
|
188
|
+
if missing:
|
|
189
|
+
plan.notes.append(
|
|
190
|
+
f"Status field has no option(s) {', '.join(missing)}; it is left as it is, "
|
|
191
|
+
"so new items get no Status. Add them in the Project settings."
|
|
192
|
+
)
|
|
193
|
+
if world.project is None:
|
|
194
|
+
plan.lines.append("+ fields Phase, Kind, Priority, Size, and the Status options")
|
|
195
|
+
|
|
196
|
+
to_create = [i for i in backlog.issues if i.id not in remote]
|
|
197
|
+
for issue in to_create:
|
|
198
|
+
plan.lines.append(f"+ issue {issue.id}: {issue.title}")
|
|
199
|
+
|
|
200
|
+
for epic in (i for i in backlog.issues if i.type == "epic"):
|
|
201
|
+
children = backlog.children_of(epic.id)
|
|
202
|
+
if not children:
|
|
203
|
+
continue
|
|
204
|
+
if epic.id not in remote or any(c.id not in remote for c in children):
|
|
205
|
+
plan.lines.append(f"~ epic {epic.id}: list {len(children)} child issue(s)")
|
|
206
|
+
elif (
|
|
207
|
+
with_children_block(remote[epic.id].body, _epic_numbers(backlog, epic, remote))
|
|
208
|
+
!= remote[epic.id].body
|
|
209
|
+
):
|
|
210
|
+
plan.lines.append(f"~ epic {epic.id}: update the child issue list")
|
|
211
|
+
|
|
212
|
+
by_number = {i.number: i for i in world.items if i.number is not None}
|
|
213
|
+
new_items = fill = 0
|
|
214
|
+
for issue in backlog.issues:
|
|
215
|
+
existing = remote.get(issue.id)
|
|
216
|
+
item = by_number.get(existing.number) if existing else None
|
|
217
|
+
if item is None:
|
|
218
|
+
new_items += 1
|
|
219
|
+
continue
|
|
220
|
+
wanted = _item_values(backlog, issue)
|
|
221
|
+
if any(
|
|
222
|
+
key not in item.values
|
|
223
|
+
and wanted[key][1] in world.fields.get(wanted[key][0], SelectField("", "")).options
|
|
224
|
+
for key in wanted
|
|
225
|
+
):
|
|
226
|
+
fill += 1
|
|
227
|
+
if new_items:
|
|
228
|
+
plan.lines.append(
|
|
229
|
+
f"+ items {new_items} issue(s) added to the Project with their fields"
|
|
230
|
+
)
|
|
231
|
+
if fill:
|
|
232
|
+
plan.lines.append(f"~ items {fill} existing item(s) get their empty fields filled")
|
|
233
|
+
return plan
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# --- applying ----------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _ensure_fields(
|
|
240
|
+
provider: GitHubProvider,
|
|
241
|
+
owner: str,
|
|
242
|
+
project: ProjectRef,
|
|
243
|
+
backlog: Backlog,
|
|
244
|
+
items: list[ProjectItem],
|
|
245
|
+
) -> dict[str, SelectField]:
|
|
246
|
+
fields = provider.project_fields(owner, project)
|
|
247
|
+
for name, options in _wanted_fields(backlog).items():
|
|
248
|
+
current = fields.get(name)
|
|
249
|
+
if current is None:
|
|
250
|
+
provider.create_select_field(project, name, [SelectOption(o) for o in options])
|
|
251
|
+
elif missing := [o for o in options if o not in current.options]:
|
|
252
|
+
keep = provider.field_options(current.id) # existing options keep their ids
|
|
253
|
+
provider.set_select_options(current.id, keep + [SelectOption(o) for o in missing])
|
|
254
|
+
|
|
255
|
+
status = fields.get("Status")
|
|
256
|
+
if status and set(status.options) == _DEFAULT_STATUS and not items:
|
|
257
|
+
provider.set_select_options(status.id, STATUS_OPTIONS)
|
|
258
|
+
return provider.project_fields(owner, project)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def apply_issues(
|
|
262
|
+
provider: GitHubProvider,
|
|
263
|
+
owner: str,
|
|
264
|
+
backlog: Backlog,
|
|
265
|
+
target: Target,
|
|
266
|
+
state: State,
|
|
267
|
+
save: Callable[[], None], # called after every state change, so progress survives a crash
|
|
268
|
+
) -> list[str]:
|
|
269
|
+
"""Create/sync the Project and issues. Returns non-blocking notes for the user."""
|
|
270
|
+
notes: list[str] = []
|
|
271
|
+
world = read_world(provider, owner, target)
|
|
272
|
+
|
|
273
|
+
project = world.project
|
|
274
|
+
if project is None:
|
|
275
|
+
project = provider.create_project(owner, target.title)
|
|
276
|
+
world.items = []
|
|
277
|
+
state.set_id("project", "number", str(project.number))
|
|
278
|
+
state.set_id("project", "title", target.title)
|
|
279
|
+
state.set_id("project", "id", project.id)
|
|
280
|
+
save()
|
|
281
|
+
provider.link_project(owner, project)
|
|
282
|
+
fields = _ensure_fields(provider, owner, project, backlog, world.items)
|
|
283
|
+
|
|
284
|
+
# Issues: epics first, so their children can say "Part of #N".
|
|
285
|
+
remote = world.remote_by_local_id(state)
|
|
286
|
+
ordered = sorted(backlog.issues, key=lambda i: i.type != "epic")
|
|
287
|
+
for issue in ordered:
|
|
288
|
+
if issue.id in remote:
|
|
289
|
+
state.set_id("issues", issue.id, str(remote[issue.id].number))
|
|
290
|
+
continue
|
|
291
|
+
parent = remote.get(issue.parent) if issue.parent else None
|
|
292
|
+
phase = next(p for p in backlog.phases if p.id == issue.phase)
|
|
293
|
+
created = provider.create_issue(
|
|
294
|
+
issue.title,
|
|
295
|
+
build_body(issue, parent.number if parent else None),
|
|
296
|
+
phase.title,
|
|
297
|
+
issue_labels(issue),
|
|
298
|
+
)
|
|
299
|
+
remote[issue.id] = created
|
|
300
|
+
state.set_id("issues", issue.id, str(created.number))
|
|
301
|
+
save()
|
|
302
|
+
save()
|
|
303
|
+
|
|
304
|
+
for epic in (i for i in backlog.issues if i.type == "epic"):
|
|
305
|
+
numbers = _epic_numbers(backlog, epic, remote)
|
|
306
|
+
if not numbers:
|
|
307
|
+
continue
|
|
308
|
+
current = remote[epic.id]
|
|
309
|
+
body = with_children_block(current.body, numbers)
|
|
310
|
+
if body != current.body:
|
|
311
|
+
provider.update_issue_body(current.number, body)
|
|
312
|
+
remote[epic.id] = RemoteIssue(current.number, current.title, body, current.url)
|
|
313
|
+
|
|
314
|
+
by_number = {i.number: i for i in world.items if i.number is not None}
|
|
315
|
+
skipped: set[str] = set()
|
|
316
|
+
for issue in backlog.issues:
|
|
317
|
+
issue_remote = remote[issue.id]
|
|
318
|
+
item = by_number.get(issue_remote.number)
|
|
319
|
+
item_id = item.id if item else provider.add_item(owner, project, issue_remote.url)
|
|
320
|
+
have = item.values if item else {}
|
|
321
|
+
values = {}
|
|
322
|
+
for key, (field_name, option) in _item_values(backlog, issue).items():
|
|
323
|
+
if key in have:
|
|
324
|
+
continue # never overwrite: the user may have changed it on purpose
|
|
325
|
+
select = fields.get(field_name)
|
|
326
|
+
if select is None or option not in select.options:
|
|
327
|
+
skipped.add(f"{field_name}={option}")
|
|
328
|
+
continue
|
|
329
|
+
values[select.id] = select.options[option]
|
|
330
|
+
provider.set_item_values(project, item_id, values)
|
|
331
|
+
state.set_id("items", issue.id, item_id)
|
|
332
|
+
save()
|
|
333
|
+
|
|
334
|
+
if skipped:
|
|
335
|
+
notes.append(
|
|
336
|
+
"Some field values could not be set because the option does not exist in the "
|
|
337
|
+
f"Project ({', '.join(sorted(skipped))}). Add the options in the Project settings."
|
|
338
|
+
)
|
|
339
|
+
return notes
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def record_existing(backlog: Backlog, world: World, state: State) -> bool:
|
|
343
|
+
"""Write what already exists on GitHub into the state, without touching GitHub.
|
|
344
|
+
|
|
345
|
+
Lets a lost or stale ``state.json`` be rebuilt by a run that has nothing to change.
|
|
346
|
+
Returns whether the state changed.
|
|
347
|
+
"""
|
|
348
|
+
before = {k: dict(v) for k, v in state.provider_ids.items()}
|
|
349
|
+
if world.project:
|
|
350
|
+
state.set_id("project", "number", str(world.project.number))
|
|
351
|
+
state.set_id("project", "id", world.project.id)
|
|
352
|
+
remote = world.remote_by_local_id(state)
|
|
353
|
+
by_number = {i.number: i for i in world.items if i.number is not None}
|
|
354
|
+
for issue in backlog.issues:
|
|
355
|
+
if issue.id not in remote:
|
|
356
|
+
continue
|
|
357
|
+
state.set_id("issues", issue.id, str(remote[issue.id].number))
|
|
358
|
+
if item := by_number.get(remote[issue.id].number):
|
|
359
|
+
state.set_id("items", issue.id, item.id)
|
|
360
|
+
return state.provider_ids != before
|