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,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