diff --git a/docs/1.architecture/3.helm-sync-python.md b/docs/1.architecture/3.helm-sync-python.md new file mode 100644 index 00000000000..1a6d2f4d3d5 --- /dev/null +++ b/docs/1.architecture/3.helm-sync-python.md @@ -0,0 +1,39 @@ +--- +title: Python Helm Sync Planning +description: Describes the pure Python parsing, routing, rendering, and planning slice under development. +icon: 'i-heroicons-cpu-chip' +tags: ['helm', 'python', 'architecture'] +--- + +## Overview + +The `python/helm_sync` package is the first migration slice for Helm's board reconciliation logic. +It ports backlog parsing, configuration validation, local-home union, project routing, desired-card rendering, and fieldwise planning into immutable Python records. +The existing Bash entry points remain the production path while later migration slices add state and execution. + +`BoardRef` is the complete route key and contains an owner plus a positive Project number. +Project routing uses the registered project identity from local homes and falls back to the configured default board. +The planner receives a fresh board snapshot, desired cards, and a previously read state snapshot. +It makes no network calls and performs no filesystem writes. + +## Usage + +Run the fixture-only parity suite with: + +```bash +bin/fm-test-run.sh tests/fm-helm-sync-python.test.sh +``` + +The test harness sources the production jq functions from `bin/fm-helm-lib.sh` and compares parser, renderer, and planner output byte-for-byte. +Planner fixtures use only `fixture-owner` and boards `999` and `1000`. + +## API Reference + +See [the Python Helm API reference](../2.api/3.helm-sync-python.md) for the public module functions and domain records. + +## Notes + +This slice does not read or write Helm state, call GitHub Projects, execute plan actions, or provide a CLI. +Those capabilities belong to later migration steps and must keep the current shell implementation active until their parity is proven. + +The planner also does not yet reconcile board cards that are missing or captain-deleted: it only plans cards whose task id is present in both the desired set and the current board snapshot. The production jq planner's retention, tombstone, and moved-card handling (`bin/fm-helm-lib.sh`) is deliberately deferred to the follow-up state/executor migration slice, not ported here. diff --git a/docs/2.api/3.helm-sync-python.md b/docs/2.api/3.helm-sync-python.md new file mode 100644 index 00000000000..8d405bc23ff --- /dev/null +++ b/docs/2.api/3.helm-sync-python.md @@ -0,0 +1,340 @@ +--- +title: Python Helm Sync API +description: Reference for the pure Python Helm parsing, routing, rendering, and planning functions. +icon: 'i-heroicons-code-bracket' +tags: ['helm', 'python', 'api'] +--- + +## Overview + +`python/helm_sync` exposes immutable domain records and pure functions for the first Helm migration slice. +The package is available from the repository root with `PYTHONPATH=python`. + +## Usage + +```python +from pathlib import Path + +from helm_sync.backlog import parse_backlog +from helm_sync.desired import render_cards +from helm_sync.model import BoardRef, Owner, ProjectNumber + +records = parse_backlog(Path("data/backlog.md").read_text(encoding="utf-8")) +default_board = BoardRef(Owner("fixture-owner"), ProjectNumber(999)) +``` + +`render_cards` also needs the registered project names, routing map, and task IDs whose main-home reports exist. +`plan_board` consumes an already-read `BoardSnapshot`, desired cards, and `SyncState`. + +## API Reference + +### `BoardRef(owner, number)` + +Identifies a board by a validated owner login and positive Project number. + +### `Owner` + +Brands a validated GitHub owner login. + +### `ProjectNumber` + +Brands a positive Project number. + +### `TaskId` + +Brands a backlog task identifier. + +### `HomeId` + +Brands a local-home identifier. + +### `ItemId` + +Brands a board item identifier. + +### `OptionId` + +Brands a single-select option identifier. + +### `ProjectName` + +Brands a registered project identity. + +### `StatusName` + +Brands a Helm Status display value. + +### `KindName` + +Brands a Helm Kind display value. + +### `PriorityName` + +Brands a Helm Priority display value. + +### `ReportPath` + +Brands a report path included in a rendered card body. + +### `Signature` + +Brands a normalized board snapshot signature. + +### `InputHash` + +Brands a digest of effective sync inputs. + +### `Fingerprint` + +Brands a serialized divergence fingerprint. + +### `FieldName` + +Restricts a Helm field name to the literal union `Status | Priority | Project | Kind`. + +### `HomeRef(id, path, main=False)` + +Identifies a local home and its filesystem root. + +### `BacklogRecord(order, state, structured, ...)` + +Holds one parsed structured or unstructured backlog row, including metadata, notes, and optional home provenance. + +### `DesiredCard(task, home, board, title, body, status, kind, project, priority, priority_n, ...)` + +Holds the canonical title, body, status, kind, project, priority, report, and route for one backlog task. + +### `CardSnapshot(item, content, fields, task=None)` + +Captures one current board item. +DraftContent and IssueContent are separate variants so the planner can preserve Issue text. + +### `DraftContent(node_id, title, body)` + +Represents editable text content for a draft card. + +### `IssueContent(node_id, title, body)` + +Represents Issue text that the planner preserves. + +### `CardContent` + +Unites the `DraftContent` and `IssueContent` variants used by `CardSnapshot`. + +### `FieldValue(name, value, option_id=None)` + +Represents one selected field value on a card. + +### `BoardField(id, name, options)` + +Describes one board field and its available single-select options. + +### `BoardSnapshot(board, cards, fields)` + +Captures the current cards and schema for one board. + +### `CardBaseline(task, item, board, node_id, is_issue, status_option, priority_option, title, body, ...)` + +Stores the last acknowledged synchronized values for one card. + +### `DispatchMarker(task, item, option, fingerprint)` + +Identifies a previously recorded dispatch request. + +### `DeletedCardTombstone(task, item)` + +Prevents automatic recreation of a card after a captain deletion. + +### `DivergenceKey(kind, task, item, fingerprint)` + +Identifies one fingerprinted field divergence. + +### `SyncState(cards, dispatches, tombstones, divergences, poll_signatures, ...)` + +Provides the immutable state snapshot consumed by a pure board planner. + +### `FieldWrite(field_id, name, value, option_id)` + +Describes one proposed single-select field update. + +### `WakeRequest(key, payload)` + +Describes one keyed wake requested by a reconciliation decision. + +### `DivergenceChange(kind, action, item, fingerprint)` + +Describes a divergence fingerprint to keep or remove. + +### `CreateDraft(task, desired, field_writes, baseline, acknowledge, fingerprint, divergence_changes, note)` + +Describes creation of one draft card and its initial field values, including any unsupported-repository note marker transition. + +### `UpdateDraft(task, item, draft_issue_id, title, body, field_writes, expected, baseline, acknowledge, ...)` + +Describes a guarded draft text or field update with its expected snapshot and acknowledgement. + +### `UpdateIssueFields(task, item, field_writes, expected, baseline, acknowledge, ...)` + +Describes guarded field updates that preserve an Issue's title and body. + +### `CloseMissingCard(task, item, field_write, expected, acknowledge)` + +Describes closing a previously synchronized card that is no longer in the desired set. + +### `RecordDispatchRequest(task, item, option, fingerprint)` + +Describes recording a new captain dispatch request. + +### `ClearDispatchRequest(task)` + +Describes removing an obsolete dispatch request. + +### `WritePriorityToBacklog(task, home_path, priority)` + +Describes writing a valid captain Priority edit into its owning backlog. + +### `HoldDeletedTask(task, home_path, reason)` + +Describes a captain hold created after deletion of a live task's card. + +### `KeepDeletedTombstone(task, item)` + +Describes retaining a deleted-card tombstone. + +### `RaiseWake(task, wake)` + +Describes one keyed wake action. + +### `RememberDivergence(key)` + +Describes recording a divergence fingerprint. + +### `ForgetDivergence(kind, task, item)` + +Describes removing obsolete divergence memory. + +### `NoChange(task, item, baseline, expected, ...)` + +Carries the baseline advancement authorized by a decision that needs no board write. + +### `BoardPlan(board, actions, snapshot_signature)` + +Groups typed reconciliation actions for one board snapshot. + +### `PlanAction` + +Unites the immutable actions accepted in a `BoardPlan`. + +### `BoardWork(board, cards)` + +Groups desired cards routed to one complete board identity. + +### `PlanError` + +Reports a planner snapshot or schema that cannot be reconciled safely. + +### `SettingsError` + +Reports invalid or unsafe enabled Helm configuration. + +### `FleetInputError` + +Reports malformed local-home or backlog input. + +### `Disabled` + +Represents an absent opt-in configuration file. + +### `HelmSettings` + +Holds the validated default board, dispatch Status option, and source path returned by `load_settings`. + +### `SettingsResult` + +Unites `Disabled` and `HelmSettings`, the two possible results of `load_settings`. + +### `parse_owner(value: object) -> Owner` + +Validates and brands a GitHub owner login at a configuration or routing boundary. + +### `parse_project_number(value: object) -> ProjectNumber` + +Validates and brands a positive Project number. + +### `parse_task_id(value: object) -> TaskId` + +Validates and brands a backlog task identifier. + +### `parse_home_id(value: object) -> HomeId` + +Validates and brands a local-home identifier. + +### `parse_item_id(value: object) -> ItemId` + +Validates and brands a single-line board item identifier. + +### `parse_backlog(text: str) -> tuple[BacklogRecord, ...]` + +Parses backlog headings, structured rows, metadata, body lines, URLs, and unstructured rows. +The output matches `fm_helm_backlog_parse_program`. + +### `record_to_dict(record: BacklogRecord) -> dict[str, object]` + +Converts a parsed record to the JSON-compatible shape emitted by the jq parser. + +### `load_settings(home: Path) -> Disabled | HelmSettings` + +Reads `config/helm.json`. +Returns `Disabled` when that opt-in file is absent and raises `SettingsError` when a present file is invalid or unsafe. + +### `settings_from_mapping(raw: object, config_path: Path) -> HelmSettings` + +Validates already-decoded configuration data at the JSON boundary. + +### `discover_local_homes(main_home: Path, registry_path: Path) -> tuple[HomeRef, ...]` + +Returns the main home and valid local homes in registry order. +Remote, malformed, and relative-path entries are skipped. + +### `parse_home_backlog(home: HomeRef) -> tuple[BacklogRecord, ...]` + +Reads, parses, validates, and tags one local home's backlog. + +### `load_fleet(homes: tuple[HomeRef, ...]) -> tuple[BacklogRecord, ...]` + +Parses each local backlog once and rejects invalid rows and duplicate task IDs across homes. + +### `registered_projects(homes: tuple[HomeRef, ...]) -> tuple[str, ...]` + +Returns unique project names in first-seen home and file order. + +### `project_for_repo(repo: str | None, registered: Sequence[str]) -> str | None` + +Resolves a backlog repository value to its registered project identity. + +### `board_for_project(project, route_map, default_board) -> BoardRef` + +Returns a valid active or migrating project route, or the configured default board. + +### `route_cards(cards, default_board, retention_board=None) -> tuple[BoardWork, ...]` + +Groups desired cards by complete board identity. +Orders the optional retention board first, then the default, then remaining boards by owner and number. + +### `render_card(record, registered_projects, route_map, default_board, report_ids=frozenset()) -> DesiredCard` + +Renders one structured backlog record into its canonical desired card. + +### `render_cards(records, registered_projects, route_map, default_board, report_ids=frozenset()) -> tuple[DesiredCard, ...]` + +Renders an ordered backlog union once into desired cards. + +### `plan_board(snapshot, desired, state, force=False, dispatch_status="In flight", epoch="0") -> BoardPlan` + +Computes a pure fieldwise plan from a fresh board snapshot. +The three-way comparison preserves captain edits, applies backlog edits that do not conflict, records convergence, and retains prior baselines for conflicting fields. + +## Notes + +The planner returns typed actions, including `CreateDraft`, `UpdateDraft`, `UpdateIssueFields`, and `NoChange`. +The package has no state adapter, GraphQL client, executor, or command-line entry point in this migration slice. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 750a95b6e8f..bcec11d5bd5 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -444,6 +444,14 @@ "path": "docs/1.architecture/helm-project-routing.md", "audience": "maintainer-architecture" }, + { + "path": "docs/1.architecture/3.helm-sync-python.md", + "audience": "maintainer-architecture" + }, + { + "path": "docs/2.api/3.helm-sync-python.md", + "audience": "maintainer-architecture" + }, { "path": "docs/2.api/guides/helm-project-routing.md", "audience": "operator-current" diff --git a/python/helm_sync/__init__.py b/python/helm_sync/__init__.py new file mode 100644 index 00000000000..2e72cd46083 --- /dev/null +++ b/python/helm_sync/__init__.py @@ -0,0 +1,5 @@ +"""Pure Python building blocks for the Helm sync migration.""" + +from .model import BoardRef, CardSnapshot, DesiredCard + +__all__ = ["BoardRef", "CardSnapshot", "DesiredCard"] diff --git a/python/helm_sync/backlog.py b/python/helm_sync/backlog.py new file mode 100644 index 00000000000..7d8bf61275f --- /dev/null +++ b/python/helm_sync/backlog.py @@ -0,0 +1,184 @@ +"""Parse backlog Markdown into records matching the production jq program.""" + +from __future__ import annotations + +import re +from dataclasses import replace + +from .model import BacklogRecord, TaskId + +_WS = r"[ \t\r\n\v\f]" +_WS_PLUS = _WS + "+" +_URL = re.compile(r'https?://[^ \t\r\n\v\f)"<>]+') +_ROW = re.compile(r"^-[ \t\r\n\v\f]+\[(?P[ xX])\][ \t\r\n\v\f]+(?P[^ \t\r\n\v\f]+)[ \t\r\n\v\f]+-[ \t\r\n\v\f]+(?P.*)$") +_TRAILING_METADATA = re.compile( + r"[ \t\r\n\v\f]*\([ \t\r\n\v\f]*(?:(?:repo|kind|priority|hold|hold-kind|hold-until):[ \t\r\n\v\f]*[^)]*|(?:since|merged|reported|done):?[ \t\r\n\v\f]+[^)]*)[ \t\r\n\v\f]*\)[ \t\r\n\v\f]*$" +) +_TITLE_ARTIFACTS = ( + re.compile(r"[ \t\r\n\v\f]+-[ \t\r\n\v\f]+data/[^ \t\r\n\v\f)]+/report\.md$"), + re.compile(r"[ \t\r\n\v\f]+data/[^ \t\r\n\v\f)]+/report\.md$"), + re.compile(r"[ \t\r\n\v\f]+-[ \t\r\n\v\f]+local main$"), + re.compile(r"[ \t\r\n\v\f]+local main$"), + re.compile(r"[ \t\r\n\v\f]+-[ \t\r\n\v\f]*$"), +) +_SPACE_RUN = re.compile(_WS + "+") + + +def _trim(value: str) -> str: + return value.strip(" \t\r\n\v\f") + + +def _capture(rest: str, pattern: str) -> str | None: + match = re.search(pattern, rest) + return _trim(match.group("v")) if match else None + + +def _metadata(rest: str, key: str) -> str | None: + return _capture( + rest, + r".*(?:\(|," + _WS + r"*)" + re.escape(key) + r":" + _WS + r"*(?P[^,)]*)", + ) + + +def _metadata_word(rest: str, key: str) -> str | None: + return _capture( + rest, + r".*(?:\(|," + _WS + r"*)" + re.escape(key) + r":?" + _WS_PLUS + r"(?P[^,)]*)", + ) + + +def _clean_title(value: str) -> str: + cleaned = value + for _ in range(20): + updated = _TRAILING_METADATA.sub("", cleaned, count=1) + if updated == cleaned: + break + cleaned = updated + for pattern in _TITLE_ARTIFACTS: + cleaned = pattern.sub("", cleaned, count=1) + return _trim(_SPACE_RUN.sub(" ", cleaned)) + + +def _title_of(rest: str) -> str: + title = _URL.sub("", rest) + title = re.sub( + r"[ \t\r\n\v\f]*blocked-by:[ \t\r\n\v\f]+[^ \t\r\n\v\f)]+[ \t\r\n\v\f]+-[ \t\r\n\v\f]+.*$", + "", + title, + count=1, + ) + title = re.sub(r"[ \t\r\n\v\f]*blocked-by:[ \t\r\n\v\f]+[^ \t\r\n\v\f]+", "", title) + return _clean_title(title) + + +def _blocked_by_ids(rest: str) -> tuple[str, ...]: + found = re.findall(r"blocked-by:[ \t\r\n\v\f]+([^ \t\r\n\v\f)]+)", rest) + return tuple(dict.fromkeys(found)) + + +def _section_state(heading: str) -> str | None: + value = _trim(heading) + return {"In flight": "in_flight", "Queued": "queued", "Done": "done"}.get(value) + + +def parse_backlog(text: str) -> tuple[BacklogRecord, ...]: + """Parse backlog text using the production jq program's record rules. + + Args: + text: Complete UTF-8 decoded contents of one backlog file. + + Returns: + Records in source order, including unstructured rows. + """ + lines = text.split("\n") + if lines and lines[-1] == "": + lines.pop() + section: str | None = None + records: list[BacklogRecord] = [] + order = 0 + + for line in lines: + if re.match(r"^##[ \t\r\n\v\f]+", line): + section = _section_state(re.sub(r"^##[ \t\r\n\v\f]+", "", line, count=1)) + continue + if section is None or _trim(line) == "": + continue + + row = _ROW.match(line) + if row: + order += 1 + rest = row.group("rest") + report_match = re.search(r".*(?Pdata/[^ \t\r\n\v\f)]+/report\.md).*", rest) + records.append( + BacklogRecord( + order=order, + state=section, + structured=True, + id=TaskId(_trim(row.group("id"))), + checked=bool(re.search(r"[xX]", row.group("check"))), + title=_title_of(rest), + repo=_metadata(rest, "repo"), + kind=_metadata(rest, "kind"), + priority=_metadata(rest, "priority"), + hold_reason=_metadata(rest, "hold"), + hold_kind=_metadata(rest, "hold-kind"), + since=_metadata_word(rest, "since"), + merged=_metadata_word(rest, "merged"), + reported=_metadata_word(rest, "reported"), + done=_metadata_word(rest, "done"), + blocked_by_ids=_blocked_by_ids(rest), + pr_url=next( + ( + url + for url in _URL.findall(rest) + if re.search(r"/pull/[1-9][0-9]*$", url) + or re.search(r"/-/merge_requests/[1-9][0-9]*$", url) + ), + None, + ), + report_path=_trim(report_match.group("v")) if report_match else None, + ) + ) + elif line[:1] in " \t\r\n\v\f" and records and records[-1].structured: + body = _trim(line) + if body: + records[-1] = replace(records[-1], body_lines=records[-1].body_lines + (body,)) + else: + order += 1 + records.append(BacklogRecord(order=order, state=section, structured=False, raw=line)) + + return tuple(records) + + +def record_to_dict(record: BacklogRecord) -> dict[str, object]: + """Convert one parsed record to jq-compatible JSON data. + + Args: + record: One structured or unstructured parsed backlog record. + + Returns: + A JSON-compatible object with the same fields and ordering as jq output. + """ + if not record.structured: + return {"order": record.order, "state": record.state, "structured": False, "raw": record.raw} + return { + "order": record.order, + "state": record.state, + "structured": True, + "id": record.id, + "checked": record.checked, + "title": record.title, + "repo": record.repo, + "kind": record.kind, + "priority": record.priority, + "hold_reason": record.hold_reason, + "hold_kind": record.hold_kind, + "since": record.since, + "merged": record.merged, + "reported": record.reported, + "done": record.done, + "blocked_by_ids": list(record.blocked_by_ids), + "pr_url": record.pr_url, + "report_path": record.report_path, + "body_lines": list(record.body_lines), + } diff --git a/python/helm_sync/desired.py b/python/helm_sync/desired.py new file mode 100644 index 00000000000..1dc37d244fe --- /dev/null +++ b/python/helm_sync/desired.py @@ -0,0 +1,194 @@ +"""Render backlog records into canonical Helm cards without side effects.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Mapping, Sequence + +from .model import ( + BoardRef, + BacklogRecord, + DesiredCard, + HomeId, + KindName, + PriorityName, + ProjectName, + ReportPath, + StatusName, + TaskId, + parse_task_id, +) +from .routing import board_for_project, project_for_repo + + +def _value(record: BacklogRecord | Mapping[str, object], key: str, default: object = None) -> object: + if isinstance(record, BacklogRecord): + return getattr(record, key, default) + return record.get(key, default) + + +def _kind(record: BacklogRecord | Mapping[str, object]) -> str: + hold_kind = _value(record, "hold_kind") + hold_reason = _value(record, "hold_reason") or "" + raw_kind = _value(record, "kind") or "ship" + if hold_kind == "captain" and hold_reason != "": + return "decision" + if raw_kind in ("task", "scout"): + return "investigation" + return "ship" + + +def _priority(value: object) -> str: + raw = str(value) + return f"P{raw}" if raw in {"0", "1", "2", "3", "4"} else "P3" + + +def _status(state: object, kind: str) -> str: + if state == "done": + return "Done" + if kind == "decision": + return "Waiting on you" + if state == "in_flight": + return "In flight" + return "Queued" + + +def _type_line(kind: str) -> str: + return { + "ship": "ship - produces a change and a PR", + "investigation": "investigation - produces knowledge, not code", + "decision": "decision - needs your call before anything moves", + }[kind] + + +def _body( + record: BacklogRecord | Mapping[str, object], + kind: str, + priority: str, + report_path: str, +) -> str: + repo = _value(record, "repo") or "-" + if repo == "": + repo = "-" + filed = "unknown" + for key in ("since", "reported", "done", "merged"): + candidate = _value(record, key) + if candidate is not None: + filed = str(candidate) + break + hold = str(_value(record, "hold_reason") or "") + blocked = ", ".join(_value(record, "blocked_by_ids", ()) or ()) + pr_url = str(_value(record, "pr_url") or "") + task = str(_value(record, "id") or "") + lines = _value(record, "body_lines", ()) or () + body = f"`{task}`\n\n" + if kind == "decision" and hold: + body += f"## What you need to decide\n\n{hold}\n\n" + body += "## Facts\n\n" + body += f"- **Repo:** {repo}\n" + body += f"- **Type:** {_type_line(kind)}\n" + body += f"- **Priority:** {priority}\n" + body += f"- **Filed:** {filed}\n" + if blocked: + body += f"- **Blocked by:** {blocked}\n" + if report_path: + body += f"- **Report:** `{report_path}`\n" + if pr_url: + body += f"- **PR:** {pr_url}\n" + body += "\n## Notes\n\n" + body += "".join(f"{line}\n" for line in lines) + body += "\n---\n_Source of truth: `data/backlog.md` in the owning local home._" + return body + + +def render_card( + record: BacklogRecord | Mapping[str, object], + registered_projects: Sequence[str], + route_map: Mapping[str, object], + default_board: BoardRef, + report_ids: frozenset[str] = frozenset(), +) -> DesiredCard: + """Render one backlog record into its immutable desired-card record. + + Args: + record: Parsed structured backlog record, optionally tagged with home data. + registered_projects: Project identities across the local fleet. + route_map: Decoded Helm project routing map. + default_board: Validated default board destination. + report_ids: Task identifiers with a main-home report file. + + Returns: + The canonical desired card with the same field values as the jq renderer. + + Raises: + ValueError: If the record does not have a valid task identifier. + """ + raw_id = _value(record, "id") + task = parse_task_id(raw_id) + repo_value = _value(record, "repo") + repo = str(repo_value) if repo_value is not None else None + project_name = project_for_repo(repo, registered_projects) + project = project_name or "other" + kind = _kind(record) + raw_priority = _value(record, "priority") + priority_n = "3" if raw_priority is None else str(raw_priority) + priority = _priority(priority_n) + explicit_report = _value(record, "report_path") or "" + report = str(explicit_report) if explicit_report else (f"data/{task}/report.md" if str(task) in report_ids else "") + state = _value(record, "state") + status = _status(state, kind) + home_id = _value(record, "home_id") or "main" + home_backlog = _value(record, "home_backlog") + if home_backlog: + home_backlog = Path(str(home_backlog)) + home_path_value = _value(record, "home_path") + if home_path_value: + home_path = Path(str(home_path_value)) + elif home_backlog: + home_path = Path(str(home_backlog)).parent.parent + else: + home_path = None + board = board_for_project(project_name, route_map, default_board) + note = "" if project_name is not None else f"fm-helm-sync: unsupported repository {repo or ''} for {task}; using other" + return DesiredCard( + task=task, + home=HomeId(str(home_id)), + board=board, + title=str(_value(record, "title") or ""), + body=_body(record, kind, priority, report), + status=StatusName(status), + kind=KindName(kind), + project=ProjectName(project), + priority=PriorityName(priority), + priority_n=priority_n, + report_path=ReportPath(report) if report else None, + home_path=home_path, + note=note, + repository=repo, + ) + + +def render_cards( + records: Sequence[BacklogRecord | Mapping[str, object]], + registered_projects: Sequence[str], + route_map: Mapping[str, object], + default_board: BoardRef, + report_ids: frozenset[str] = frozenset(), +) -> tuple[DesiredCard, ...]: + """Render a backlog union once into its ordered desired-card sequence. + + Args: + records: Structured records collected from local homes. + registered_projects: Project identities in the local-home union. + route_map: Decoded Helm project routing map. + default_board: Validated default board destination. + report_ids: Task identifiers with a main-home report file. + + Returns: + Immutable desired cards in the same order as their backlog records. + """ + return tuple( + render_card(record, registered_projects, route_map, default_board, report_ids) + for record in records + if bool(_value(record, "structured", True)) + ) diff --git a/python/helm_sync/fleet.py b/python/helm_sync/fleet.py new file mode 100644 index 00000000000..398e292cae3 --- /dev/null +++ b/python/helm_sync/fleet.py @@ -0,0 +1,154 @@ +"""Discover local homes and build a validated union of their backlogs.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from .backlog import parse_backlog +from .model import BacklogRecord, HomeId, HomeRef, TaskId, parse_home_id, parse_task_id + +_LOCAL_HOME = re.compile( + r"^- (?P[A-Za-z0-9._-]+) - .+ \(home:[ \t]*(?P[^;)]*);[ \t]*scope:[ \t]*(.*);[ \t]*projects:[ \t]*[^;)]*;[ \t]*added[ \t]+[0-9]{4}-[0-9]{2}-[0-9]{2}\)[ \t]*$" +) +_REMOTE_HOME = re.compile( + r"^- [A-Za-z0-9._-]+ - .+ \(host:[^;)]*;[ \t]*root:[^;)]*;[ \t]*home:[^;)]*;[ \t]*scope:.*;[ \t]*projects:[^;)]*;[ \t]*added[ \t]+[0-9]{4}-[0-9]{2}-[0-9]{2}\)[ \t]*$" +) + + +class FleetInputError(ValueError): + """Report malformed or duplicate local fleet input.""" + + +def discover_local_homes(main_home: Path, registry_path: Path) -> tuple[HomeRef, ...]: + """Return the main home followed by registered local secondmate homes. + + Args: + main_home: Root of the primary local home. + registry_path: Main home's secondmate registry path. + + Returns: + The main home and valid absolute-path local homes in registry order. + Remote, malformed, and relative-path entries are skipped. + """ + homes = [HomeRef(HomeId("main"), main_home, main=True)] + if registry_path.is_symlink() or not registry_path.is_file(): + return tuple(homes) + try: + lines = registry_path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError): + return tuple(homes) + seen_ids = {HomeId("main")} + for line in lines: + if _REMOTE_HOME.fullmatch(line): + continue + match = _LOCAL_HOME.fullmatch(line) + if match is None: + continue + home_id = parse_home_id(match.group("id")) + raw_home = match.group("home").strip() + path = Path(raw_home) + if not raw_home or not path.is_absolute() or home_id in seen_ids: + continue + homes.append(HomeRef(home_id, path)) + seen_ids.add(home_id) + return tuple(homes) + + +def parse_home_backlog(home: HomeRef) -> tuple[BacklogRecord, ...]: + """Parse and validate one home's backlog records. + + Args: + home: Local home whose ``data/backlog.md`` is read. + + Returns: + Structured backlog records tagged with their owning home paths. + + Raises: + FleetInputError: If a backlog is absent, malformed, or has invalid rows. + """ + backlog_path = home.path / "data" / "backlog.md" + if backlog_path.is_symlink() or not backlog_path.is_file(): + raise FleetInputError(f"{backlog_path} is absent or not a regular file") + try: + parsed = parse_backlog(backlog_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError) as exc: + raise FleetInputError(f"{backlog_path} could not be read") from exc + records: list[BacklogRecord] = [] + seen: set[TaskId] = set() + for record in parsed: + if not record.structured or record.id is None or not record.title: + raise FleetInputError(f"{backlog_path} contains an unstructured or empty task row") + try: + task = parse_task_id(record.id) + except ValueError as exc: + raise FleetInputError(f"{backlog_path} contains an invalid task id") from exc + if task in seen: + raise FleetInputError(f"{backlog_path} contains duplicate task id {task}") + seen.add(task) + records.append( + BacklogRecord( + **{ + **record.__dict__, + "id": task, + "home_id": home.id, + "home_backlog": backlog_path, + "home_path": home.path, + } + ) + ) + return tuple(records) + + +def load_fleet(homes: tuple[HomeRef, ...]) -> tuple[BacklogRecord, ...]: + """Parse each local home once and reject duplicate IDs across the union. + + Args: + homes: Ordered primary and local secondmate homes. + + Returns: + The ordered union of each home's validated backlog records. + + Raises: + FleetInputError: If one task id appears in more than one home. + """ + all_records: list[BacklogRecord] = [] + owners: dict[TaskId, HomeId] = {} + for home in homes: + for record in parse_home_backlog(home): + assert record.id is not None and record.home_id is not None + prior_home = owners.get(record.id) + if prior_home is not None: + raise FleetInputError( + f"task id {record.id} appears in both homes {prior_home} and {record.home_id}" + ) + owners[record.id] = record.home_id + all_records.append(record) + return tuple(all_records) + + +def registered_projects(homes: tuple[HomeRef, ...]) -> tuple[str, ...]: + """Return project names from local home registries in first-seen order. + + Args: + homes: Ordered primary and local secondmate homes. + + Returns: + Unique project names in their first-seen home and file order. + """ + projects: list[str] = [] + seen: set[str] = set() + for home in homes: + registry = home.path / "data" / "projects.md" + if registry.is_symlink() or not registry.is_file(): + continue + try: + lines = registry.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError): + continue + for line in lines: + parts = line.split() + if len(parts) >= 2 and parts[0] == "-" and parts[1] not in seen: + seen.add(parts[1]) + projects.append(parts[1]) + return tuple(projects) diff --git a/python/helm_sync/model.py b/python/helm_sync/model.py new file mode 100644 index 00000000000..3aec6fbe972 --- /dev/null +++ b/python/helm_sync/model.py @@ -0,0 +1,544 @@ +"""Immutable domain records shared by the Helm sync planning modules.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Literal, Mapping, NewType, TypeAlias + +Owner = NewType("Owner", str) +ProjectNumber = NewType("ProjectNumber", int) +TaskId = NewType("TaskId", str) +HomeId = NewType("HomeId", str) +ItemId = NewType("ItemId", str) +OptionId = NewType("OptionId", str) +ProjectName = NewType("ProjectName", str) +StatusName = NewType("StatusName", str) +KindName = NewType("KindName", str) +PriorityName = NewType("PriorityName", str) +ReportPath = NewType("ReportPath", str) +Signature = NewType("Signature", str) +InputHash = NewType("InputHash", str) +Fingerprint = NewType("Fingerprint", str) +FieldName = Literal["Status", "Priority", "Project", "Kind"] + +_OWNER_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$") +_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$") + + +def parse_owner(value: object) -> Owner: + """Validate and brand a GitHub owner login. + + Args: + value: Untrusted owner value from configuration or routing data. + + Returns: + A validated Owner string. + + Raises: + ValueError: If the value is not a valid owner login. + """ + if not isinstance(value, str) or not _OWNER_RE.fullmatch(value): + raise ValueError("board owner must be a valid non-empty login") + return Owner(value) + + +def parse_project_number(value: object) -> ProjectNumber: + """Validate and brand a positive GitHub Project number. + + Args: + value: Untrusted number value from configuration or routing data. + + Returns: + A validated positive ProjectNumber. + + Raises: + ValueError: If the value is not a positive integer. + """ + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError("board number must be a positive integer") + return ProjectNumber(value) + + +def parse_task_id(value: object) -> TaskId: + """Validate and brand a backlog task identifier. + + Args: + value: Untrusted task ID from a parsed backlog or board card. + + Returns: + A validated TaskId. + + Raises: + ValueError: If the value contains characters outside the task ID grammar. + """ + if not isinstance(value, str) or not _ID_RE.fullmatch(value): + raise ValueError("task id must contain only letters, digits, dot, underscore, or hyphen") + return TaskId(value) + + +def parse_home_id(value: object) -> HomeId: + """Validate and brand a registered local-home identifier. + + Args: + value: Untrusted home ID from the secondmate registry. + + Returns: + A validated HomeId. + + Raises: + ValueError: If the value contains characters outside the home ID grammar. + """ + if not isinstance(value, str) or not _ID_RE.fullmatch(value): + raise ValueError("home id must contain only letters, digits, dot, underscore, or hyphen") + return HomeId(value) + + +def parse_item_id(value: object) -> ItemId: + """Validate and brand a synthetic or GitHub board item identifier. + + Args: + value: Untrusted item ID read from a board snapshot. + + Returns: + A validated ItemId. + + Raises: + ValueError: If the value is empty or contains a line or field separator. + """ + if not isinstance(value, str) or not value or any(ch in value for ch in "\t\r\n\0"): + raise ValueError("item id must be a non-empty single-line string") + return ItemId(value) + + +@dataclass(frozen=True, order=True) +class BoardRef: + """Identify a GitHub Project by owner and board number.""" + + owner: Owner + number: ProjectNumber + + def __post_init__(self) -> None: + object.__setattr__(self, "owner", parse_owner(self.owner)) + object.__setattr__(self, "number", parse_project_number(self.number)) + + +@dataclass(frozen=True) +class HomeRef: + """Identify a local Helm home and its filesystem root.""" + + id: HomeId + path: Path + main: bool = False + + +@dataclass(frozen=True) +class BacklogRecord: + """Represent one structured or unstructured backlog line group.""" + + order: int + state: str | None + structured: bool + id: TaskId | None = None + checked: bool | None = None + title: str | None = None + repo: str | None = None + kind: str | None = None + priority: str | None = None + hold_reason: str | None = None + hold_kind: str | None = None + since: str | None = None + merged: str | None = None + reported: str | None = None + done: str | None = None + blocked_by_ids: tuple[str, ...] = () + pr_url: str | None = None + report_path: str | None = None + body_lines: tuple[str, ...] = () + raw: str | None = None + home_id: HomeId | None = None + home_backlog: Path | None = None + home_path: Path | None = None + + +@dataclass(frozen=True) +class DesiredCard: + """Hold the canonical backlog-owned fields rendered for one card.""" + + task: TaskId + home: HomeId + board: BoardRef + title: str + body: str + status: StatusName + kind: KindName + project: ProjectName + priority: PriorityName + priority_n: str + report_path: ReportPath | None = None + home_path: Path | None = None + note: str = "" + repository: str | None = None + + +@dataclass(frozen=True) +class DraftContent: + """Represent editable title and body content for a draft card.""" + + node_id: str + title: str + body: str + + +@dataclass(frozen=True) +class IssueContent: + """Represent Issue content whose title and body Helm must preserve.""" + + node_id: str + title: str + body: str + + +CardContent: TypeAlias = DraftContent | IssueContent + + +@dataclass(frozen=True) +class FieldValue: + """Represent one board field's display value and selected option id.""" + + name: FieldName + value: str + option_id: OptionId | None = None + + +@dataclass(frozen=True) +class BoardField: + """Describe one single-select field and its available named options.""" + + id: str + name: FieldName + options: Mapping[str, OptionId] + + def __post_init__(self) -> None: + object.__setattr__(self, "options", MappingProxyType(dict(self.options))) + + +@dataclass(frozen=True) +class CardSnapshot: + """Capture one current board item for a pure reconciliation pass.""" + + item: ItemId + content: CardContent + fields: Mapping[FieldName, FieldValue] + task: TaskId | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "fields", MappingProxyType(dict(self.fields))) + + +@dataclass(frozen=True) +class BoardSnapshot: + """Capture the current cards and schema for one board.""" + + board: BoardRef + cards: tuple[CardSnapshot, ...] + fields: Mapping[FieldName, BoardField] + + def __post_init__(self) -> None: + object.__setattr__(self, "cards", tuple(self.cards)) + object.__setattr__(self, "fields", MappingProxyType(dict(self.fields))) + + +@dataclass(frozen=True) +class CardBaseline: + """Remember the last acknowledged values for one synchronized card.""" + + task: TaskId + item: ItemId + board: BoardRef + node_id: str + is_issue: bool + status_option: str + priority_option: str + title: str + body: str + epoch: str = "" + version_two: bool = True + + +@dataclass(frozen=True) +class DispatchMarker: + """Remember one captain dispatch request already queued for a card.""" + + task: TaskId + item: ItemId + option: OptionId + fingerprint: Fingerprint + + +@dataclass(frozen=True) +class DeletedCardTombstone: + """Keep a deleted board-card identity from being recreated automatically.""" + + task: TaskId + item: ItemId + + +@dataclass(frozen=True) +class DivergenceKey: + """Identify one field divergence and its current fingerprint.""" + + kind: str + task: TaskId + item: ItemId + fingerprint: Fingerprint + + +@dataclass(frozen=True) +class SyncState: + """Provide the immutable state snapshot consumed by a board planner.""" + + cards: Mapping[TaskId, CardBaseline] = field(default_factory=dict) + dispatches: Mapping[TaskId, DispatchMarker] = field(default_factory=dict) + tombstones: Mapping[TaskId, DeletedCardTombstone] = field(default_factory=dict) + divergences: frozenset[DivergenceKey] = frozenset() + poll_signatures: Mapping[BoardRef, Signature] = field(default_factory=dict) + completed_input_hash: InputHash | None = None + forced_resume: bool = False + + def __post_init__(self) -> None: + object.__setattr__(self, "cards", MappingProxyType(dict(self.cards))) + object.__setattr__(self, "dispatches", MappingProxyType(dict(self.dispatches))) + object.__setattr__(self, "tombstones", MappingProxyType(dict(self.tombstones))) + object.__setattr__(self, "divergences", frozenset(self.divergences)) + object.__setattr__(self, "poll_signatures", MappingProxyType(dict(self.poll_signatures))) + + +@dataclass(frozen=True) +class FieldWrite: + """Describe one single-select field update proposed by the planner.""" + + field_id: str + name: FieldName + value: str + option_id: OptionId + + +@dataclass(frozen=True) +class WakeRequest: + """Describe a keyed wake requested by a reconciliation decision.""" + + key: str + payload: str + + +@dataclass(frozen=True) +class DivergenceChange: + """Describe a divergence fingerprint to remember or forget.""" + + kind: str + action: Literal["keep", "remove"] + item: str + fingerprint: str + + +@dataclass(frozen=True) +class NoChange: + """Carry the baseline advancement authorized by a no-write decision.""" + + task: TaskId + item: ItemId + baseline: CardBaseline + expected: CardSnapshot + title: str = "" + body: str = "" + field_writes: tuple[FieldWrite, ...] = () + wakes: tuple[WakeRequest, ...] = () + divergence_changes: tuple[DivergenceChange, ...] = () + marker_action: str = "" + marker_fingerprint: str = "" + writeback_priority: str = "" + home_path: Path | None = None + acknowledge: Mapping[str, object] = field(default_factory=dict) + fingerprint: Fingerprint = Fingerprint("") + note: str = "" + compatibility_only: bool = False + + def __post_init__(self) -> None: + object.__setattr__(self, "acknowledge", MappingProxyType(dict(self.acknowledge))) + + +@dataclass(frozen=True) +class CreateDraft: + """Describe a new draft card and its initial field values.""" + + task: TaskId + desired: DesiredCard + field_writes: tuple[FieldWrite, ...] + baseline: CardBaseline + acknowledge: Mapping[str, object] + fingerprint: Fingerprint = Fingerprint("") + divergence_changes: tuple[DivergenceChange, ...] = () + note: str = "" + + def __post_init__(self) -> None: + object.__setattr__(self, "acknowledge", MappingProxyType(dict(self.acknowledge))) + + +@dataclass(frozen=True) +class UpdateDraft: + """Describe a guarded draft text and field update with its acknowledgement.""" + + task: TaskId + item: ItemId + draft_issue_id: str | None + title: str + body: str + text_changed: bool + field_writes: tuple[FieldWrite, ...] + expected: CardSnapshot + baseline: CardBaseline + acknowledge: Mapping[str, object] + wakes: tuple[WakeRequest, ...] = () + divergence_changes: tuple[DivergenceChange, ...] = () + marker_action: str = "" + marker_fingerprint: str = "" + writeback_priority: str = "" + home_path: Path | None = None + fingerprint: Fingerprint = Fingerprint("") + note: str = "" + + def __post_init__(self) -> None: + object.__setattr__(self, "acknowledge", MappingProxyType(dict(self.acknowledge))) + + +@dataclass(frozen=True) +class UpdateIssueFields: + """Describe field updates for a real Issue without changing its text.""" + + task: TaskId + item: ItemId + field_writes: tuple[FieldWrite, ...] + expected: CardSnapshot + baseline: CardBaseline + acknowledge: Mapping[str, object] + wakes: tuple[WakeRequest, ...] = () + divergence_changes: tuple[DivergenceChange, ...] = () + marker_action: str = "" + marker_fingerprint: str = "" + writeback_priority: str = "" + home_path: Path | None = None + fingerprint: Fingerprint = Fingerprint("") + note: str = "" + + def __post_init__(self) -> None: + object.__setattr__(self, "acknowledge", MappingProxyType(dict(self.acknowledge))) + + +@dataclass(frozen=True) +class CloseMissingCard: + """Describe closing a previously synchronized card absent from backlog.""" + + task: TaskId + item: ItemId + field_write: FieldWrite + expected: CardSnapshot + acknowledge: Mapping[str, object] + + def __post_init__(self) -> None: + object.__setattr__(self, "acknowledge", MappingProxyType(dict(self.acknowledge))) + + +@dataclass(frozen=True) +class RecordDispatchRequest: + """Describe persisting a new captain dispatch request.""" + + task: TaskId + item: ItemId + option: OptionId + fingerprint: Fingerprint + + +@dataclass(frozen=True) +class ClearDispatchRequest: + """Describe clearing an obsolete captain dispatch request.""" + + task: TaskId + + +@dataclass(frozen=True) +class WritePriorityToBacklog: + """Describe writing a valid captain Priority edit into the backlog.""" + + task: TaskId + home_path: Path + priority: str + + +@dataclass(frozen=True) +class HoldDeletedTask: + """Describe placing a captain hold after deletion of a live card.""" + + task: TaskId + home_path: Path + reason: str + + +@dataclass(frozen=True) +class KeepDeletedTombstone: + """Describe retaining a deleted-card tombstone.""" + + task: TaskId + item: ItemId + + +@dataclass(frozen=True) +class RaiseWake: + """Describe one keyed wake emitted by the board planner.""" + + task: TaskId + wake: WakeRequest + + +@dataclass(frozen=True) +class RememberDivergence: + """Describe recording a divergence fingerprint.""" + + key: DivergenceKey + + +@dataclass(frozen=True) +class ForgetDivergence: + """Describe removing obsolete divergence memory for a task field.""" + + kind: str + task: TaskId + item: ItemId + + +PlanAction: TypeAlias = ( + CreateDraft + | UpdateDraft + | UpdateIssueFields + | CloseMissingCard + | RecordDispatchRequest + | ClearDispatchRequest + | WritePriorityToBacklog + | HoldDeletedTask + | KeepDeletedTombstone + | RaiseWake + | RememberDivergence + | ForgetDivergence + | NoChange +) + + +@dataclass(frozen=True) +class BoardPlan: + """Group typed reconciliation actions and a signature for one board read.""" + + board: BoardRef + actions: tuple[PlanAction, ...] + snapshot_signature: Signature diff --git a/python/helm_sync/planner.py b/python/helm_sync/planner.py new file mode 100644 index 00000000000..1ae2d5e795f --- /dev/null +++ b/python/helm_sync/planner.py @@ -0,0 +1,833 @@ +"""Build pure, fieldwise Helm reconciliation plans from fresh snapshots.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path +from typing import Iterable, Mapping, Sequence + +from .model import ( + BoardField, + BoardPlan, + BoardSnapshot, + CardBaseline, + CardSnapshot, + CreateDraft, + DesiredCard, + DivergenceChange, + DivergenceKey, + DraftContent, + FieldWrite, + FieldName, + Fingerprint, + ItemId, + NoChange, + OptionId, + PlanAction, + Signature, + SyncState, + TaskId, + UpdateDraft, + UpdateIssueFields, + WakeRequest, + parse_item_id, + parse_task_id, +) + +_US = "\x1f" +_RS = "\x1e" + + +class PlanError(ValueError): + """Report a board snapshot that cannot be safely reconciled.""" + + +def _json(value: object) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def _b64(value: str) -> str: + return base64.b64encode(value.encode("utf-8")).decode("ascii") + + +def _item_task(card: CardSnapshot) -> TaskId | None: + if card.task is not None: + return parse_task_id(card.task) + first = card.content.body.split("\n", 1)[0] + if len(first) < 3 or first[0] != "`" or first[-1] != "`": + return None + try: + return parse_task_id(first[1:-1]) + except ValueError: + return None + + +def _field(snapshot: CardSnapshot, name: FieldName): + return snapshot.fields.get(name) + + +def _option(fields: Mapping[FieldName, BoardField], field_name: FieldName, value: str) -> str: + field = fields.get(field_name) + if field is None: + raise PlanError(f"required Helm field is unavailable: {field_name}") + option = field.options.get(value) + if option is None: + raise PlanError(f"required Helm option is unavailable: {field_name}={value}") + return str(option) + + +def _field_id(fields: Mapping[FieldName, BoardField], field_name: FieldName) -> str: + field = fields.get(field_name) + if field is None: + raise PlanError(f"required Helm field is unavailable: {field_name}") + return field.id + + +def _current_option(card: CardSnapshot, name: FieldName) -> str: + field = _field(card, name) + return str(field.option_id) if field is not None and field.option_id is not None else "" + + +def _current_value(card: CardSnapshot, name: FieldName) -> str: + field = _field(card, name) + return field.value if field is not None else "" + + +def _snapshot_json(card: CardSnapshot) -> str: + content = card.content + fields = [ + {"field": value.name, "name": value.value, "optionId": str(value.option_id or "")} + for value in card.fields.values() + if value.name + ] + fields.sort(key=lambda value: (value["field"], value["optionId"], value["name"])) + return _json({"title": content.title, "body": content.body, "fields": fields}) + + +def _fingerprint(kind: str, current: str, desired: str) -> Fingerprint: + encoded = _json([kind, current, desired]).encode("utf-8") + return Fingerprint(base64.b64encode(encoded).decode("ascii")) + + +def _divergence_exists( + divergences: Iterable[DivergenceKey], kind: str, task: TaskId, item: ItemId, fp: str +) -> bool: + return any(d.kind == kind and d.task == task and d.item == item and d.fingerprint == fp for d in divergences) + + +def _divergence_for(divergences: Iterable[DivergenceKey], kind: str, task: TaskId) -> DivergenceKey | None: + return next((d for d in divergences if d.kind == kind and d.task == task), None) + + +def _priority_digit(value: str) -> str: + return value[1:] if value in {"P0", "P1", "P2", "P3", "P4"} else "" + + +def _desired_fingerprint(desired: DesiredCard) -> Fingerprint: + body_hash = hashlib.sha256(desired.body.encode("utf-8")).hexdigest() + parts = ( + str(desired.status), + str(desired.priority), + str(desired.project), + str(desired.kind), + desired.title, + body_hash, + ) + return Fingerprint(hashlib.sha256("\0".join(parts).encode("utf-8")).hexdigest()) + + +def _cache_row(baseline: CardBaseline) -> str: + return "\t".join( + ( + str(baseline.task), + str(baseline.item), + baseline.node_id, + "issue" if baseline.is_issue else "draft", + baseline.status_option, + baseline.priority_option, + _b64(baseline.title), + _b64(baseline.body), + baseline.epoch, + str(baseline.board.owner), + str(baseline.board.number), + ) + ) + + +def _writes( + fields: Mapping[FieldName, BoardField], names: Sequence[tuple[FieldName, str]] +) -> tuple[FieldWrite, ...]: + return tuple( + FieldWrite(_field_id(fields, name), name, value, OptionId(option)) + for name, value, option in ((name, value, _option(fields, name, value)) for name, value in names) + ) + + +def _make_baseline( + task: TaskId, + card: CardSnapshot, + board: BoardSnapshot, + status_option: str, + priority_option: str, + title: str, + body: str, + epoch: str, +) -> CardBaseline: + return CardBaseline( + task=task, + item=card.item, + board=board.board, + node_id=card.content.node_id, + is_issue=not isinstance(card.content, DraftContent), + status_option=status_option, + priority_option=priority_option, + title=title, + body=body, + epoch=epoch, + ) + + +def _plan_existing( + board: BoardSnapshot, + card: CardSnapshot, + desired: DesiredCard, + old: CardBaseline | None, + state: SyncState, + *, + force: bool, + dispatch_status: str, + epoch: str, +) -> PlanAction: + task = desired.task + item = parse_item_id(card.item) + fields = board.fields + card_title = card.content.title + card_body = card.content.body + is_issue = not isinstance(card.content, DraftContent) + node_id = card.content.node_id + status_field = _field_id(fields, "Status") + project_field = _field_id(fields, "Project") + kind_field = _field_id(fields, "Kind") + priority_field = _field_id(fields, "Priority") + status_option = _option(fields, "Status", str(desired.status)) + project_option = _option(fields, "Project", str(desired.project)) + kind_option = _option(fields, "Kind", str(desired.kind)) + priority_option = _option(fields, "Priority", str(desired.priority)) + + current_status = _current_option(card, "Status") + current_priority = _current_option(card, "Priority") + rebuilt = old is None or not old.version_two + base_status = current_status if rebuilt else old.status_option + base_priority = current_priority if rebuilt else old.priority_option + base_title = card_title if rebuilt else old.title + base_body = card_body if rebuilt else old.body + + current_title_b64 = _b64(card_title) + current_body_b64 = _b64(card_body) + desired_title_b64 = _b64(desired.title) + desired_body_b64 = _b64(desired.body) + base_title_b64 = _b64(base_title) + base_body_b64 = _b64(base_body) + + status_normal = rebuilt or current_status == base_status + status_board_changed = current_status != base_status + waiting_option = _option(fields, "Status", "Waiting on you") + dispatch_option = _option(fields, "Status", dispatch_status) + done_option = _option(fields, "Status", "Done") + waiting_status_changed = current_status == waiting_option and status_board_changed + priority_board_changed = current_priority != base_priority and priority_option == base_priority + title_board_changed = current_title_b64 != base_title_b64 and desired_title_b64 == base_title_b64 + body_board_changed = current_body_b64 != base_body_b64 and desired_body_b64 == base_body_b64 + status_conflict = current_status != base_status and status_option != base_status and current_status != status_option + priority_conflict = current_priority != base_priority and priority_option != base_priority and current_priority != priority_option + title_conflict = current_title_b64 != base_title_b64 and desired_title_b64 != base_title_b64 and current_title_b64 != desired_title_b64 + body_conflict = current_body_b64 != base_body_b64 and desired_body_b64 != base_body_b64 and current_body_b64 != desired_body_b64 + any_conflict = status_conflict or priority_conflict or title_conflict or body_conflict + + title_write = not title_conflict and current_title_b64 != desired_title_b64 and not title_board_changed + body_write = not body_conflict and current_body_b64 != desired_body_b64 and not body_board_changed + text_write = title_write or body_write + + title_fp = _fingerprint("title", current_title_b64, desired_title_b64) + body_fp = _fingerprint("body", current_body_b64, desired_body_b64) + status_fp = _fingerprint("status", current_status, status_option) + priority_fp = _fingerprint("priority", current_priority, priority_option) + title_edit = title_board_changed or title_conflict + body_edit = body_board_changed or body_conflict + title_edit_wake = title_edit and not _divergence_exists( + state.divergences, "card-edit-title", task, item, title_fp + ) + body_edit_wake = body_edit and not _divergence_exists( + state.divergences, "card-edit-body", task, item, body_fp + ) + conflict_wake = ( + (status_conflict and not _divergence_exists(state.divergences, "conflict-status", task, item, status_fp)) + or (priority_conflict and not _divergence_exists(state.divergences, "conflict-priority", task, item, priority_fp)) + or (title_conflict and not _divergence_exists(state.divergences, "conflict-title", task, item, title_fp)) + or (body_conflict and not _divergence_exists(state.divergences, "conflict-body", task, item, body_fp)) + ) + + current_status_value = _current_value(card, "Status") + current_priority_value = _current_value(card, "Priority") + priority_digit = _priority_digit(current_priority_value) + writeback = priority_digit if priority_board_changed and priority_digit else "" + dispatch = ( + current_status == dispatch_option + and status_board_changed + and str(desired.status) != dispatch_status + and str(desired.status) != "Done" + ) + dispatch_fp = f"{item}:{dispatch_option}" + marker_action = "" + wakes: list[WakeRequest] = [] + if not is_issue and (title_edit_wake or body_edit_wake): + wakes.append( + WakeRequest( + f"helm-card-edit:{task}", + f"check: captain edited Helm card {task} text; reconcile it into the backlog", + ) + ) + if dispatch: + marker = state.dispatches.get(task) + if marker is not None and str(marker.fingerprint) == dispatch_fp: + marker_action = "" + else: + marker_action = "request" + wakes.append( + WakeRequest( + f"helm-dispatch:{task}", + f"check: Helm dispatch request for {task} (board item {item})", + ) + ) + elif task in state.dispatches: + marker_action = "remove" + + deferred = False + status_deferred_kind = "" + status_deferred_fp = "" + if not dispatch and current_status_value != str(desired.status) and current_status == waiting_option: + waiting_fp = f"{current_status}:{status_option}" + marker = _divergence_for(state.divergences, "status-waiting", task) + if status_board_changed or (marker is not None and marker.fingerprint == waiting_fp): + deferred = True + status_deferred_kind = "status-waiting" + status_deferred_fp = waiting_fp + if marker is None or marker.fingerprint != waiting_fp: + wakes.append( + WakeRequest( + f"helm-status-waiting:{task}", + f"check: captain moved Helm card {task} to Waiting on you; reconcile it into the backlog", + ) + ) + elif not dispatch and status_board_changed and current_status_value != str(desired.status): + if current_status_value == "Done" and str(desired.status) != "Done": + deferred = True + status_deferred_kind = "status-done" + status_deferred_fp = current_status + marker = _divergence_for(state.divergences, status_deferred_kind, task) + if marker is None or marker.fingerprint != status_deferred_fp: + wakes.append( + WakeRequest( + f"helm-status-done:{task}", + f"check: captain moved Helm card {task} to Done while the task is live; confirm and reconcile", + ) + ) + elif (current_status_value == "Queued" and str(desired.status) in {"In flight", "Done"}) or ( + current_status_value == "In flight" and str(desired.status) == "Done" + ): + deferred = True + status_deferred_kind = "status-back" + status_deferred_fp = current_status + marker = _divergence_for(state.divergences, status_deferred_kind, task) + if marker is None or marker.fingerprint != status_deferred_fp: + wakes.append( + WakeRequest( + f"helm-status-back:{task}", + f"check: captain moved Helm card {task} back to {current_status_value}; reconcile it into the backlog", + ) + ) + if conflict_wake: + wakes.append( + WakeRequest( + f"helm-card-edit:{task}", + f"check: Helm card {task} changed on both board and backlog; reconcile the conflict", + ) + ) + + field_writes: list[FieldWrite] = [] + if not status_conflict and not dispatch and not deferred and status_normal and current_status_value != str(desired.status): + field_writes.append(FieldWrite(status_field, "Status", str(desired.status), OptionId(status_option))) + if _current_option(card, "Project") != project_option: + field_writes.append(FieldWrite(project_field, "Project", str(desired.project), OptionId(project_option))) + if _current_option(card, "Kind") != kind_option: + field_writes.append(FieldWrite(kind_field, "Kind", str(desired.kind), OptionId(kind_option))) + if not priority_conflict and not writeback and _current_option(card, "Priority") != priority_option: + field_writes.append(FieldWrite(priority_field, "Priority", str(desired.priority), OptionId(priority_option))) + + if status_conflict: + cache_status = base_status + elif any(write.name == "Status" for write in field_writes): + cache_status = status_option + elif waiting_status_changed: + cache_status = current_status + elif rebuilt or status_normal or current_status == status_option: + cache_status = status_option + else: + cache_status = base_status + + if priority_conflict: + cache_priority = base_priority + elif any(write.name == "Priority" for write in field_writes): + cache_priority = priority_option + elif rebuilt: + cache_priority = priority_option + elif priority_board_changed: + cache_priority = current_priority + else: + cache_priority = priority_option + + text_board_changed = title_board_changed or body_board_changed + cache_title = ( + desired_title_b64 + if title_write or rebuilt or current_title_b64 == desired_title_b64 or not title_board_changed + else base_title_b64 + ) + if title_conflict: + cache_title = base_title_b64 + cache_body = ( + desired_body_b64 + if body_write or rebuilt or current_body_b64 == desired_body_b64 or not body_board_changed + else base_body_b64 + ) + if body_conflict: + cache_body = base_body_b64 + baseline = _make_baseline( + task, + card, + board, + cache_status, + cache_priority, + _decode_b64(cache_title), + _decode_b64(cache_body), + epoch, + ) + + note, note_changes = _unsupported_repo_note(desired, state) + unsupported_marker = _divergence_for(state.divergences, "unsupported-repo", task) + + # A normal sync can avoid reprocessing an unchanged desired baseline. Poll + # requests set force so captain edits reach this three-way comparison. + if ( + not force + and old is not None + and old.version_two + and old.status_option == status_option + and old.priority_option == priority_option + and old.title == desired.title + and old.body == desired.body + and not desired.note + and unsupported_marker is None + ): + if any_conflict: + early_baseline = _make_baseline( + task, + card, + board, + base_status, + base_priority, + base_title, + base_body, + epoch, + ) + else: + early_title = ( + desired_title_b64 + if rebuilt or (current_title_b64 == desired_title_b64 and current_body_b64 == desired_body_b64) or not text_board_changed + else base_title_b64 + ) + early_body = ( + desired_body_b64 + if rebuilt or (current_title_b64 == desired_title_b64 and current_body_b64 == desired_body_b64) or not text_board_changed + else base_body_b64 + ) + early_baseline = _make_baseline( + task, + card, + board, + cache_status, + cache_priority, + _decode_b64(early_title), + _decode_b64(early_body), + epoch, + ) + return NoChange( + task, + item, + early_baseline, + card, + fingerprint=Fingerprint(""), + note=note, + compatibility_only=True, + ) + + div_changes: list[DivergenceChange] = list(note_changes) + _change(div_changes, state, "card-edit-title", task, item, title_fp, title_edit) + _change(div_changes, state, "card-edit-body", task, item, body_fp, body_edit) + _change(div_changes, state, "conflict-status", task, item, status_fp, status_conflict) + _change(div_changes, state, "conflict-priority", task, item, priority_fp, priority_conflict) + _change(div_changes, state, "conflict-title", task, item, title_fp, title_conflict) + _change(div_changes, state, "conflict-body", task, item, body_fp, body_conflict) + for kind, fp in (("card-edit", ""), ("conflict", ""), ("new-card", ""), ("card-deleted", "")): + _change(div_changes, state, kind, task, item, fp, False, remove_existing=True) + if status_deferred_kind: + _change(div_changes, state, status_deferred_kind, task, item, status_deferred_fp, True) + else: + for kind in ("status-back", "status-done", "status-waiting"): + _change(div_changes, state, kind, task, item, "", False, remove_existing=True) + + acknowledge: dict[str, object] = { + "new": False, + "text": text_write and not is_issue, + "title": desired.title if title_write else card_title, + "body": desired.body if body_write else card_body, + "fields": [ + {"name": write.name, "value": write.value, "option": str(write.option_id)} + for write in field_writes + ], + } + final_title = desired.title if title_write else card_title + final_body = desired.body if body_write else card_body + common = { + "wakes": tuple(wakes), + "divergence_changes": tuple(div_changes), + "marker_action": marker_action, + "marker_fingerprint": dispatch_fp, + "writeback_priority": writeback, + "home_path": desired.home_path, + "fingerprint": _desired_fingerprint(desired), + "note": note, + } + if is_issue: + action_type = UpdateIssueFields if field_writes else NoChange + if action_type is NoChange: + return NoChange( + task, + item, + baseline, + card, + title=final_title, + body=final_body, + field_writes=tuple(field_writes), + acknowledge=acknowledge, + **common, + ) + return UpdateIssueFields( + task, + item, + tuple(field_writes), + card, + baseline, + acknowledge, + **common, + ) + if text_write or field_writes: + return UpdateDraft( + task=task, + item=item, + draft_issue_id=node_id if text_write else None, + title=final_title, + body=final_body, + text_changed=text_write, + field_writes=tuple(field_writes), + expected=card, + baseline=baseline, + acknowledge=acknowledge, + **common, + ) + return NoChange( + task, + item, + baseline, + card, + title=final_title, + body=final_body, + field_writes=tuple(field_writes), + acknowledge=acknowledge, + **common, + ) + + +def _decode_b64(value: str) -> str: + return base64.b64decode(value.encode("ascii")).decode("utf-8") + + +def _change( + changes: list[DivergenceChange], + state: SyncState, + kind: str, + task: TaskId, + item: ItemId, + fp: str, + active: bool, + *, + remove_existing: bool = False, +) -> None: + existing = _divergence_for(state.divergences, kind, task) + if active: + changes.append(DivergenceChange(kind, "keep", str(item), fp)) + elif remove_existing and existing is not None: + changes.append(DivergenceChange(kind, "remove", str(item), "")) + + +def _record_repo(card: DesiredCard) -> str: + return card.repository or "" + + +def _unsupported_repo_note( + desired: DesiredCard, + state: SyncState, +) -> tuple[str, tuple[DivergenceChange, ...]]: + """Return the visible unsupported-repository note and its marker update.""" + task = desired.task + marker = _divergence_for(state.divergences, "unsupported-repo", task) + if desired.note: + fingerprint = Fingerprint(_b64(_json(_record_repo(desired)))) + note = "" if _divergence_exists( + state.divergences, + "unsupported-repo", + task, + ItemId(""), + str(fingerprint), + ) else desired.note + return note, (DivergenceChange("unsupported-repo", "keep", "", str(fingerprint)),) + if marker is not None: + return "", (DivergenceChange("unsupported-repo", "remove", "", ""),) + return "", () + + +def plan_board( + snapshot: BoardSnapshot, + desired: Mapping[TaskId, DesiredCard] | Sequence[DesiredCard], + state: SyncState, + *, + force: bool = False, + dispatch_status: str = "In flight", + epoch: str = "0", +) -> BoardPlan: + """Plan fieldwise reconciliation for one fresh board snapshot. + + Args: + snapshot: Fresh board contents and schema. + desired: Desired cards keyed by task id or provided in source order. + state: Previously acknowledged cache and divergence markers. + force: Whether the caller requires a full reconciliation pass. + dispatch_status: Status option used to recognize a dispatch request. + epoch: Run epoch recorded in resulting card baselines. + + Returns: + An immutable board plan with one primary action per desired card. + + Raises: + PlanError: If the board contains duplicate card identities or required schema. + """ + desired_cards = tuple(desired.values()) if isinstance(desired, Mapping) else tuple(desired) + desired_by_task = {card.task: card for card in desired_cards if card.board == snapshot.board} + if len(desired_by_task) != len([card for card in desired_cards if card.board == snapshot.board]): + raise PlanError("desired cards contain duplicate task identifiers") + cards_by_task: dict[TaskId, CardSnapshot] = {} + for card in snapshot.cards: + task = _item_task(card) + if task is None or task not in desired_by_task: + continue + if task in cards_by_task: + raise PlanError(f"duplicate Helm cards for {task}") + cards_by_task[task] = card + + actions: list[PlanAction] = [] + for task, wanted in desired_by_task.items(): + current = cards_by_task.get(task) + old = state.cards.get(task) + if current is None: + note, note_changes = _unsupported_repo_note(wanted, state) + field_writes = _writes( + snapshot.fields, + ( + ("Status", str(wanted.status)), + ("Project", str(wanted.project)), + ("Kind", str(wanted.kind)), + ("Priority", str(wanted.priority)), + ), + ) + baseline = CardBaseline( + task=task, + item=ItemId(""), + board=snapshot.board, + node_id="", + is_issue=False, + status_option=str(field_writes[0].option_id), + priority_option=str(field_writes[-1].option_id), + title=wanted.title, + body=wanted.body, + epoch=epoch, + ) + actions.append( + CreateDraft( + task, + wanted, + field_writes, + baseline, + {"new": True, "title": wanted.title, "body": wanted.body}, + _desired_fingerprint(wanted), + note_changes, + note, + ) + ) + continue + actions.append( + _plan_existing( + snapshot, + current, + wanted, + old, + state, + force=force, + dispatch_status=dispatch_status, + epoch=epoch, + ) + ) + + canonical = [ + { + "item": str(card.item), + "task": str(_item_task(card) or ""), + "title": card.content.title, + "body": card.content.body, + "fields": sorted( + (value.name, value.value, str(value.option_id or "")) for value in card.fields.values() + ), + } + for card in snapshot.cards + ] + signature = hashlib.sha256(_json(canonical).encode("utf-8")).hexdigest() + return BoardPlan(snapshot.board, tuple(actions), Signature(signature)) + + +def _legacy_action_bytes(action: PlanAction) -> bytes: + """Project a typed card decision to the live jq stream for parity tests.""" + if isinstance(action, CreateDraft): + return _legacy_create_bytes(action) + if not isinstance(action, (NoChange, UpdateDraft, UpdateIssueFields)): + return b"" + baseline = action.baseline + if isinstance(action, NoChange) and action.compatibility_only: + values = ( + "record", "none", str(baseline.task), str(baseline.item), _cache_row(baseline), + "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", action.note, + ) + return "\0".join(values).encode("utf-8") + b"\0" + draft_id = action.draft_issue_id or "" if isinstance(action, UpdateDraft) else "" + title = action.title if isinstance(action, (NoChange, UpdateDraft)) else str(action.acknowledge["title"]) + body = action.body if isinstance(action, (NoChange, UpdateDraft)) else str(action.acknowledge["body"]) + writes = action.field_writes if isinstance(action, (NoChange, UpdateDraft, UpdateIssueFields)) else () + wakes = action.wakes + div_changes = action.divergence_changes + expected = _snapshot_json(action.expected) + acknowledge = _json(dict(action.acknowledge)) + home = str(action.home_path) if action.home_path else "" + marker = action.marker_action + marker_fp = action.marker_fingerprint + writeback = action.writeback_priority + fingerprint = str(action.fingerprint) + note = action.note + action_name = "none" if isinstance(action, NoChange) else "update" + fields_text = _US.join( + _RS.join((write.field_id, write.name, write.value, str(write.option_id))) for write in writes + ) + wakes_text = _US.join(_RS.join((wake.key, wake.payload)) for wake in wakes) + div_text = _US.join( + _RS.join((change.kind, change.action, change.item, change.fingerprint)) for change in div_changes + ) + values = ( + "record", + action_name, + str(baseline.task), + str(baseline.item), + _cache_row(baseline), + draft_id, + title, + body, + fields_text, + wakes_text, + marker, + marker_fp, + div_text, + writeback, + home, + "", + "", + expected, + "", + acknowledge, + fingerprint, + note, + ) + return "\0".join(values).encode("utf-8") + b"\0" + + +def _legacy_create_bytes(action: CreateDraft) -> bytes: + writes = action.field_writes + fields_text = _US.join( + _RS.join((write.field_id, write.name, write.value, str(write.option_id))) for write in writes + ) + div_text = _US.join( + _RS.join((change.kind, change.action, change.item, change.fingerprint)) + for change in action.divergence_changes + ) + baseline = action.baseline + cache = "\t".join( + ( + str(baseline.task), + "", + "", + "draft", + baseline.status_option, + baseline.priority_option, + _b64(baseline.title), + _b64(baseline.body), + baseline.epoch, + str(baseline.board.owner), + str(baseline.board.number), + ) + ) + values = ( + "record", + "create", + str(action.task), + "", + cache, + "", + action.desired.title, + action.desired.body, + fields_text, + "", + "", + "", + div_text, + "", + str(action.desired.home_path or ""), + "", + "", + _json({"title": action.desired.title, "body": action.desired.body, "fields": []}), + _json(dict(action.acknowledge)), + _json({"new": False, "text": False, "fields": [{"name": w.name, "value": w.value, "option": str(w.option_id)} for w in writes]}), + str(action.fingerprint), + action.note, + ) + return "\0".join(values).encode("utf-8") + b"\0" + + +def _serialize_plan_for_jq_parity(plan: BoardPlan) -> bytes: + """Encode a typed board plan in the existing jq format for parity tests.""" + return b"".join(_legacy_action_bytes(action) for action in plan.actions) diff --git a/python/helm_sync/routing.py b/python/helm_sync/routing.py new file mode 100644 index 00000000000..f5899c751f8 --- /dev/null +++ b/python/helm_sync/routing.py @@ -0,0 +1,95 @@ +"""Resolve project identities and group desired cards by complete board route.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Sequence + +from .model import BoardRef, DesiredCard, parse_owner, parse_project_number + + +@dataclass(frozen=True) +class BoardWork: + """Group one board reference with the desired cards routed to it.""" + + board: BoardRef + cards: tuple[DesiredCard, ...] + + +def project_for_repo(repo: str | None, registered: Sequence[str]) -> str | None: + """Resolve a backlog repository value to its registered project identity. + + Args: + repo: Repository name or owner/repository value from the backlog. + registered: Project names read from local homes' project registries. + + Returns: + The matching registered name, ``other`` for that explicit fallback, or None. + """ + value = repo or "" + base = value.rsplit("/", 1)[-1] + if value in registered: + return value + if base in registered: + return base + if value == "other": + return "other" + return None + + +def board_for_project( + project: str | None, + route_map: Mapping[str, object], + default_board: BoardRef, +) -> BoardRef: + """Resolve an active or migrating project mapping, else use the default. + + Args: + project: Registered project identity resolved from the backlog. + route_map: Decoded ``data/helm-project-map.json`` object. + default_board: Validated default destination. + + Returns: + The mapped board when its entry is valid and current, otherwise default. + """ + projects = route_map.get("projects", {}) + if project is None or not isinstance(projects, Mapping): + return default_board + entry = projects.get(project) + if not isinstance(entry, Mapping): + return default_board + state = entry.get("state", "active") + if state not in ("active", "migrating"): + return default_board + owner = entry.get("owner") + number = entry.get("number") + try: + return BoardRef(parse_owner(owner), parse_project_number(number)) + except ValueError: + return default_board + + +def route_cards( + cards: Sequence[DesiredCard], + default_board: BoardRef, + *, + retention_board: BoardRef | None = None, +) -> tuple[BoardWork, ...]: + """Group desired cards and return retention/default-first board order. + + Args: + cards: Rendered desired cards from all local homes. + default_board: Board included even when it has no desired cards. + retention_board: Optional board that must be processed before default. + + Returns: + Board groups ordered retention, default, then owner and number. + """ + grouped: dict[BoardRef, list[DesiredCard]] = {default_board: []} + if retention_board is not None: + grouped.setdefault(retention_board, []) + for card in cards: + grouped.setdefault(card.board, []).append(card) + pinned = tuple(dict.fromkeys(board for board in (retention_board, default_board) if board in grouped)) + remainder = sorted((board for board in grouped if board not in pinned), key=lambda b: (b.owner, b.number)) + return tuple(BoardWork(board, tuple(grouped[board])) for board in pinned + tuple(remainder)) diff --git a/python/helm_sync/settings.py b/python/helm_sync/settings.py new file mode 100644 index 00000000000..023cd7dee85 --- /dev/null +++ b/python/helm_sync/settings.py @@ -0,0 +1,89 @@ +"""Read and validate Helm's opt-in configuration boundary.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +from .model import BoardRef, StatusName, parse_owner, parse_project_number + + +@dataclass(frozen=True) +class Disabled: + """Represent an absent Helm configuration without reading other inputs.""" + + +@dataclass(frozen=True) +class HelmSettings: + """Hold the validated default board and dispatch Status option.""" + + default_board: BoardRef + dispatch_status: StatusName + config_path: Path + + +class SettingsError(ValueError): + """Report an invalid or unsafe Helm configuration file.""" + + +SettingsResult = Disabled | HelmSettings + + +def load_settings(home: Path) -> SettingsResult: + """Load Helm configuration, returning Disabled when opt-in is absent. + + Args: + home: Root of the local Helm home. + + Returns: + Disabled when ``config/helm.json`` is absent, otherwise validated settings. + + Raises: + SettingsError: If configuration is linked, unreadable, malformed, or invalid. + """ + config_path = home / "config" / "helm.json" + if config_path.is_symlink(): + raise SettingsError("config/helm.json must be a regular non-symlink file") + if not config_path.exists(): + return Disabled() + if not config_path.is_file(): + raise SettingsError("config/helm.json must be a regular non-symlink file") + try: + content = "\n".join( + line for line in config_path.read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith(("//", "#")) + ) + raw = json.loads(content) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise SettingsError("config/helm.json could not be read as JSON") from exc + return settings_from_mapping(raw, config_path) + + +def settings_from_mapping(raw: object, config_path: Path) -> HelmSettings: + """Validate decoded configuration data at the JSON boundary. + + Args: + raw: Decoded configuration value. + config_path: Source path retained for diagnostics and future consumers. + + Returns: + A validated HelmSettings record. + + Raises: + SettingsError: If required values have invalid types or values. + """ + if not isinstance(raw, Mapping): + raise SettingsError("config/helm.json must contain an object") + try: + owner = parse_owner(raw.get("owner")) + number = parse_project_number(raw.get("number")) + except ValueError as exc: + raise SettingsError(f"config/helm.json has invalid default board: {exc}") from exc + dispatch_status = raw.get("dispatch_status") + if dispatch_status is None: + dispatch_status = "In flight" + if not isinstance(dispatch_status, str) or not dispatch_status: + raise SettingsError("config/helm.json has an empty or invalid dispatch_status") + return HelmSettings(BoardRef(owner, number), StatusName(dispatch_status), config_path) diff --git a/python/helm_sync/tests/jq_parity.py b/python/helm_sync/tests/jq_parity.py new file mode 100644 index 00000000000..924f51a500e --- /dev/null +++ b/python/helm_sync/tests/jq_parity.py @@ -0,0 +1,770 @@ +"""Parity fixtures that compare Python Helm behavior with the live jq programs.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from typing import Mapping + +ROOT = Path(__file__).resolve().parents[3] +os.sys.path.insert(0, str(ROOT / "python")) + +from helm_sync.backlog import parse_backlog, record_to_dict +from helm_sync.desired import render_cards +from helm_sync.fleet import FleetInputError, discover_local_homes, load_fleet, registered_projects +from helm_sync.model import ( + BoardField, + BoardRef, + BoardSnapshot, + CardBaseline, + CardSnapshot, + DesiredCard, + DivergenceKey, + DraftContent, + FieldValue, + Fingerprint, + HomeId, + IssueContent, + ItemId, + KindName, + OptionId, + Owner, + PriorityName, + ProjectName, + ProjectNumber, + StatusName, + SyncState, + TaskId, +) +from helm_sync.planner import _serialize_plan_for_jq_parity, plan_board +from helm_sync.routing import board_for_project, route_cards +from helm_sync.settings import Disabled, SettingsError, load_settings + +TICK = chr(96) + + +def _production_program(function: str) -> str: + """Read a jq program by sourcing the production Helm library function.""" + result = subprocess.run( + [ + "bash", + "-c", + 'source "$1"; "$2"', + "jq-parity", + str(ROOT / "bin" / "fm-helm-lib.sh"), + function, + ], + check=True, + capture_output=True, + ) + return result.stdout.decode("utf-8") + + +def _compact_json(value: object) -> bytes: + """Encode compact UTF-8 JSON with jq-compatible separators.""" + return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n" + + +PARSER_FIXTURES = ( + """## Queued +- [ ] fixture-meta - Trim metadata (repo: sample, kind: task, priority: 1, since: 2026-08-01) +""", + """## In flight +- [x] fixture-hold - Captain decision (hold: choose a route, hold-kind: captain, reported 2026-08-02) +""", + """## Queued +- [ ] fixture-blocked - Unblock work blocked-by: fixture-parent blocked-by: fixture-parent blocked-by: fixture-other +""", + """## Done +- [x] fixture-links - Shipped https://example.test/pull/31 and https://example.test/group/-/merge_requests/7 data/fixture-links/report.md + first note + second note +""", + """## Unknown section +- [ ] ignored - this row is ignored +## Queued +- unstructured row +- [ ] fixture-artifact - Clean this title - data/fixture-artifact/report.md (repo: sample) (done 2026-08-03) +""", +) + + +DESIRED_FIXTURE = """## Queued +- [ ] fixture-ship - Ship a setting (repo: sample, priority: 2, since: 2026-09-01) +- [ ] fixture-decision - Choose a direction (repo: sample, hold: choose now, hold-kind: captain) +- [ ] fixture-scout - Inspect behavior (repo: sample, kind: scout, priority: 1) +- [ ] fixture-blocked - Wait for prerequisite (repo: sample, blocked-by: fixture-ship) +- [ ] fixture-unsupported - Inspect a repo (repo: mystery/repo) +- [ ] fixture-pr - Repair the bug https://example.test/pull/43 (repo: sample) + Reproduce before changing anything. +## In flight +- [ ] fixture-active - Continue work (repo: sample, priority: 0) +## Done +- [x] fixture-done - Close the loop (repo: sample, done 2026-09-02) +""" + + +def _tag_home(record: Mapping[str, object]) -> dict[str, object]: + """Add only the fixture home fields consumed by desired rendering.""" + return { + **record, + "home_id": "main", + "home_backlog": "/fixture/main/data/backlog.md", + } + + +def _desired_dict(card: DesiredCard) -> dict[str, object]: + """Build the jq desired object shape from a typed desired card.""" + return { + "title": card.title, + "body": card.body, + "status": str(card.status), + "kind": str(card.kind), + "project": str(card.project), + "priority_n": card.priority_n, + "priority": str(card.priority), + "board": {"owner": str(card.board.owner), "number": int(card.board.number)}, + "note": card.note, + } + + +def _jq_desired_output(records: list[dict[str, object]], report_ids: list[str]) -> bytes: + """Run the production desired renderer and select each exact desired object.""" + with tempfile.TemporaryDirectory(prefix="helm-jq-desired-") as temp: + root = Path(temp) + routing_path = root / "routing.json" + records_path = root / "records.json" + routing_path.write_bytes( + _compact_json( + { + "version": 1, + "projects": { + "sample": {"owner": "fixture-owner", "number": 1000, "state": "active"} + }, + } + ) + ) + records_path.write_bytes(_compact_json(records)) + rendered = subprocess.run( + [ + "jq", + "-c", + "--slurpfile", + "routing", + str(routing_path), + "--argjson", + "registered", + '["sample"]', + "--arg", + "default_owner", + "fixture-owner", + "--argjson", + "default_number", + "999", + "--argjson", + "report_ids", + json.dumps(report_ids), + _production_program("fm_helm_desired_program"), + str(records_path), + ], + check=True, + capture_output=True, + ) + selected = subprocess.run( + ["jq", "-c", ".[] | .desired"], + input=rendered.stdout, + check=True, + capture_output=True, + ) + return selected.stdout + + +def _all_fields() -> dict[str, BoardField]: + """Build a complete fixture board schema with fake option IDs.""" + return { + "Status": BoardField( + "field_status", + "Status", + { + "Queued": OptionId("option_status_queued"), + "In flight": OptionId("option_status_flight"), + "Waiting on you": OptionId("option_status_waiting"), + "Done": OptionId("option_status_done"), + }, + ), + "Project": BoardField( + "field_project", + "Project", + {"sample": OptionId("option_project_sample"), "other": OptionId("option_project_other")}, + ), + "Kind": BoardField( + "field_kind", + "Kind", + { + "ship": OptionId("option_kind_ship"), + "investigation": OptionId("option_kind_investigation"), + "decision": OptionId("option_kind_decision"), + }, + ), + "Priority": BoardField( + "field_priority", + "Priority", + {f"P{index}": OptionId(f"option_priority_{index}") for index in range(5)}, + ), + } + + +def _status_id(value: str) -> str: + """Return the fixture option ID for a Status name.""" + return { + "Queued": "option_status_queued", + "In flight": "option_status_flight", + "Waiting on you": "option_status_waiting", + "Done": "option_status_done", + }[value] + + +def _priority_id(value: str) -> str: + """Return the fixture option ID for a Priority name.""" + return f"option_priority_{value[1:]}" + + +def _b64(value: str) -> str: + """Return the base64 encoding used by the production card cache.""" + return base64.b64encode(value.encode("utf-8")).decode("ascii") + + +def _conflict_fp(kind: str, current: str, desired: str) -> str: + """Return the production divergence fingerprint for a conflict field.""" + current_value = _b64(current) if kind in {"title", "body"} else current + desired_value = _b64(desired) if kind in {"title", "body"} else desired + raw = json.dumps([kind, current_value, desired_value], separators=(",", ":")).encode("utf-8") + return base64.b64encode(raw).decode("ascii") + + +def _body(value: str) -> str: + """Give one body fixture a valid task identity line.""" + return f"{TICK}fixture-task{TICK}\n\n{value}" + + +def _planner_fixture( + *, + title_base: str = "Title baseline", + title_current: str | None = None, + title_desired: str | None = None, + body_base: str = "Body baseline", + body_current: str | None = None, + body_desired: str | None = None, + status_base: str = "Queued", + status_current: str | None = None, + status_desired: str = "Queued", + priority_base: str = "P3", + priority_current: str | None = None, + priority_desired: str = "P3", + note: str = "", + repository: str | None = None, + board_card: bool = True, + cached_card: bool = True, + divergences: tuple[tuple[str, str], ...] = (), + is_issue: bool = False, +) -> tuple[BoardSnapshot, DesiredCard, SyncState, dict[str, object], str, str]: + """Create matching Python and jq planner inputs using fixture identities.""" + task = TaskId("fixture-task") + item = ItemId("PVTI_fixture_card") + node = "ISSUE_fixture_card" if is_issue else "DRAFT_fixture_card" + board_ref = BoardRef(Owner("fixture-owner"), ProjectNumber(999)) + title_current = title_base if title_current is None else title_current + title_desired = title_base if title_desired is None else title_desired + body_current = body_base if body_current is None else body_current + body_desired = body_base if body_desired is None else body_desired + status_current = status_base if status_current is None else status_current + priority_current = priority_base if priority_current is None else priority_current + current_body = _body(body_current) + baseline_body = _body(body_base) + desired_body = _body(body_desired) + schema = _all_fields() + current_fields = { + "Status": FieldValue("Status", status_current, OptionId(_status_id(status_current))), + "Project": FieldValue("Project", "sample", OptionId("option_project_sample")), + "Kind": FieldValue("Kind", "ship", OptionId("option_kind_ship")), + "Priority": FieldValue("Priority", priority_current, OptionId(_priority_id(priority_current))), + } + content = ( + IssueContent(node, title_current, current_body) + if is_issue + else DraftContent(node, title_current, current_body) + ) + current_card = CardSnapshot( + item, + content, + current_fields, + task, + ) + snapshot = BoardSnapshot(board_ref, (current_card,) if board_card else (), schema) + desired = DesiredCard( + task, + HomeId("main"), + board_ref, + title_desired, + desired_body, + StatusName(status_desired), + KindName("ship"), + ProjectName("sample"), + PriorityName(priority_desired), + priority_desired[1:], + home_path=Path("/fixture/main"), + note=note, + repository=repository, + ) + baseline = CardBaseline( + task, + item, + board_ref, + node, + is_issue, + _status_id(status_base), + _priority_id(priority_base), + title_base, + baseline_body, + "1700000000", + True, + ) + divergence_set = frozenset( + DivergenceKey( + kind, + task, + ItemId("") if kind == "unsupported-repo" else item, + Fingerprint(fingerprint), + ) + for kind, fingerprint in divergences + ) + state = SyncState(cards={task: baseline} if cached_card else {}, divergences=divergence_set) + raw_card = { + "id": str(item), + "content": { + "__typename": "Issue" if is_issue else "DraftIssue", + "id": node, + "title": title_current, + "body": current_body, + }, + "fieldValues": { + "nodes": [ + { + "field": {"name": field_name}, + "name": field_value.value, + "optionId": str(field_value.option_id or ""), + } + for field_name, field_value in current_fields.items() + ] + }, + } + board_json = { + "data": { + "user": { + "projectV2": { + "fields": { + "nodes": [ + { + "__typename": "ProjectV2SingleSelectField", + "id": field.id, + "name": field.name, + "options": [ + {"id": option_id, "name": option_name} + for option_name, option_id in field.options.items() + ], + } + for field in schema.values() + ] + }, + "items": {"nodes": [raw_card] if board_card else []}, + } + } + } + } + cache_row = "\t".join( + ( + str(task), + str(item), + node, + "issue" if is_issue else "draft", + _status_id(status_base), + _priority_id(priority_base), + _b64(title_base), + _b64(baseline_body), + "1700000000", + "fixture-owner", + "999", + ) + ) + cards_tsv = cache_row if cached_card else "" + divergence_text = "".join( + f"{kind}\t{task}\t\t{fingerprint}\n" + if kind == "unsupported-repo" + else f"{kind}\t{task}\t{item}\t{fingerprint}\n" + for kind, fingerprint in divergences + ) + return snapshot, desired, state, board_json, cards_tsv, divergence_text + + +def _jq_plan_output( + board: dict[str, object], + desired: DesiredCard, + cards_tsv: str, + divergences_text: str, +) -> bytes: + """Run the production planner with one synthetic board item.""" + with tempfile.TemporaryDirectory(prefix="helm-jq-plan-") as temp: + root = Path(temp) + board_path = root / "board.json" + desired_path = root / "desired.json" + cards_path = root / "cards.tsv" + deleted_path = root / "deleted.tsv" + markers_path = root / "markers.tsv" + divergences_path = root / "divergences.tsv" + fps_path = root / "fps.tsv" + board_path.write_bytes(_compact_json(board)) + desired_record = { + "id": str(desired.task), + "state": "queued", + "home_path": "/fixture/main", + "repo": desired.repository or "", + "desired": { + "title": desired.title, + "body": desired.body, + "status": str(desired.status), + "kind": str(desired.kind), + "project": str(desired.project), + "priority_n": desired.priority_n, + "priority": str(desired.priority), + "board": {"owner": "fixture-owner", "number": 999}, + "note": desired.note, + }, + } + desired_path.write_bytes(_compact_json([desired_record])) + cards_path.write_text(cards_tsv + "\n", encoding="utf-8") + deleted_path.write_text("", encoding="utf-8") + markers_path.write_text("", encoding="utf-8") + divergences_path.write_text(divergences_text, encoding="utf-8") + body_hash = hashlib.sha256(desired.body.encode("utf-8")).hexdigest() + fingerprint = hashlib.sha256( + "\0".join( + ( + str(desired.status), + str(desired.priority), + str(desired.project), + str(desired.kind), + desired.title, + body_hash, + ) + ).encode("utf-8") + ).hexdigest() + fps_path.write_text(f"{desired.task}\t{fingerprint}\n", encoding="utf-8") + result = subprocess.run( + [ + "jq", + "-j", + "--slurpfile", + "desired", + str(desired_path), + "--rawfile", + "cards", + str(cards_path), + "--rawfile", + "deleted", + str(deleted_path), + "--rawfile", + "markers", + str(markers_path), + "--rawfile", + "divergences", + str(divergences_path), + "--rawfile", + "fps", + str(fps_path), + "--arg", + "force", + "1", + "--arg", + "dispatch_status", + "In flight", + "--arg", + "now", + "1700000001", + "--arg", + "tsv_existed", + "true", + "--arg", + "retain_source", + "0", + "--arg", + "retain_project", + "", + "--arg", + "board_owner", + "fixture-owner", + "--argjson", + "board_number", + "999", + "--arg", + "default_owner", + "fixture-owner", + "--argjson", + "default_number", + "999", + _production_program("fm_helm_plan_program"), + str(board_path), + ], + check=True, + capture_output=True, + ) + return result.stdout + + +class HelmSyncPythonParityTests(unittest.TestCase): + """Compare pure Python Helm modules with production jq behavior.""" + + def test_backlog_parser_matches_production_jq_fixtures(self) -> None: + """Match the production parser across the prototype edge-case fixture set.""" + program = _production_program("fm_helm_backlog_parse_program") + for index, fixture in enumerate(PARSER_FIXTURES, start=1): + with self.subTest(fixture=index): + actual = subprocess.run( + ["jq", "-c", "-R", "-n", program], + input=fixture.encode("utf-8"), + check=True, + capture_output=True, + ).stdout + python_records = [record_to_dict(record) for record in parse_backlog(fixture)] + self.assertEqual(_compact_json(python_records), actual) + + def test_desired_renderer_matches_production_jq_fixtures(self) -> None: + """Match card content, option names, report links, and board routes.""" + parsed = parse_backlog(DESIRED_FIXTURE) + records = [_tag_home(record_to_dict(record)) for record in parsed] + route_map = { + "version": 1, + "projects": { + "sample": {"owner": "fixture-owner", "number": 1000, "state": "active"} + }, + } + default = BoardRef(Owner("fixture-owner"), ProjectNumber(999)) + report_ids = frozenset({"fixture-scout"}) + expected = _jq_desired_output(records, ["fixture-scout"]) + rendered = render_cards(records, ["sample"], route_map, default, report_ids) + actual = b"".join(_compact_json(_desired_dict(card)) for card in rendered) + self.assertEqual(expected, actual) + + def test_all_sixteen_conflict_and_edit_plans_match_production_jq(self) -> None: + """Match five title and body cases plus the six focused planner scenarios.""" + title_base = "Title baseline" + body_base = "Body baseline" + cases: list[tuple[str, dict[str, object]]] = [] + for name, current, wanted in ( + ("board-only", "Title captain", title_base), + ("backlog-only", title_base, "Title backlog"), + ("converged", "Title converged", "Title converged"), + ("conflict", "Title captain", "Title backlog"), + ("unchanged", title_base, title_base), + ): + cases.append((f"title-{name}", {"title_current": current, "title_desired": wanted})) + for name, current, wanted in ( + ("board-only", "Body captain", body_base), + ("backlog-only", body_base, "Body backlog"), + ("converged", "Body converged", "Body converged"), + ("conflict", "Body captain", "Body backlog"), + ("unchanged", body_base, body_base), + ): + cases.append((f"body-{name}", {"body_current": current, "body_desired": wanted})) + cases.extend( + ( + ("status-conflict", {"status_current": "In flight", "status_desired": "Done"}), + ("priority-conflict", {"priority_current": "P1", "priority_desired": "P4"}), + ("captain-priority-writeback", {"priority_current": "P1", "priority_desired": "P3"}), + ( + "repeated-conflict-no-second-wake", + {"title_current": "Title captain", "title_desired": "Title backlog"}, + ), + ( + "mixed-title-and-body-edit", + { + "title_current": "Title captain", + "title_desired": title_base, + "body_current": body_base, + "body_desired": "Body backlog", + }, + ), + ( + "utf8-title-conflict", + { + "title_base": "Café baseline 🛟", + "title_current": "Café captain 🛟", + "title_desired": "Café backlog 🛟", + }, + ), + ) + ) + self.assertEqual(16, len(cases)) + for name, overrides in cases: + with self.subTest(scenario=name): + divergences: tuple[tuple[str, str], ...] = () + if name == "repeated-conflict-no-second-wake": + fingerprint = _conflict_fp("title", "Title captain", "Title backlog") + divergences = ( + ("card-edit-title", fingerprint), + ("conflict-title", fingerprint), + ) + snapshot, wanted, state, raw_board, cards_tsv, divergence_text = _planner_fixture( + **overrides, + divergences=divergences, + ) + plan = plan_board(snapshot, (wanted,), state, force=True, epoch="1700000001") + python_output = _serialize_plan_for_jq_parity(plan) + jq_output = _jq_plan_output(raw_board, wanted, cards_tsv, divergence_text) + self.assertEqual(jq_output, python_output) + + def test_issue_title_and_body_writes_match_production_jq(self) -> None: + """Match Issue title/body write decisions and acknowledgement for six focused cases.""" + title_base = "Title baseline" + body_base = "Body baseline" + cases: tuple[tuple[str, dict[str, object]], ...] = ( + ("title-board-only", {"title_current": "Title captain", "title_desired": title_base}), + ("title-backlog-only", {"title_current": title_base, "title_desired": "Title backlog"}), + ("title-conflict", {"title_current": "Title captain", "title_desired": "Title backlog"}), + ("body-board-only", {"body_current": "Body captain", "body_desired": body_base}), + ("body-backlog-only", {"body_current": body_base, "body_desired": "Body backlog"}), + ("body-conflict", {"body_current": "Body captain", "body_desired": "Body backlog"}), + ) + for name, overrides in cases: + with self.subTest(scenario=name): + snapshot, wanted, state, raw_board, cards_tsv, divergence_text = _planner_fixture( + is_issue=True, + **overrides, + ) + plan = plan_board(snapshot, (wanted,), state, force=True, epoch="1700000001") + python_output = _serialize_plan_for_jq_parity(plan) + jq_output = _jq_plan_output(raw_board, wanted, cards_tsv, divergence_text) + self.assertEqual(jq_output, python_output) + + def test_unsupported_repository_notice_is_debounced_and_cleared(self) -> None: + """Match the standing note marker lifecycle for one unsupported repo.""" + repo = "fixture-unsupported/repo" + fingerprint = base64.b64encode(_compact_json(repo).rstrip(b"\n")).decode("ascii") + cases = ( + ("first-notice", "Repository is unsupported", repo, ()), + ("repeat-silenced", "Repository is unsupported", repo, (("unsupported-repo", fingerprint),)), + ("cleared", "", None, (("unsupported-repo", fingerprint),)), + ) + for name, note, repository, divergences in cases: + with self.subTest(scenario=name): + snapshot, wanted, state, raw_board, cards_tsv, divergence_text = _planner_fixture( + note=note, + repository=repository, + divergences=divergences, + ) + plan = plan_board(snapshot, (wanted,), state, force=True, epoch="1700000001") + python_output = _serialize_plan_for_jq_parity(plan) + jq_output = _jq_plan_output(raw_board, wanted, cards_tsv, divergence_text) + self.assertEqual(jq_output, python_output) + + def test_unsupported_repository_notice_on_new_card_matches_production_jq(self) -> None: + """Carry the unsupported-repository note through the draft-create plan.""" + snapshot, wanted, state, raw_board, cards_tsv, divergence_text = _planner_fixture( + note="Repository is unsupported", + repository="fixture-unsupported/repo", + board_card=False, + cached_card=False, + ) + plan = plan_board(snapshot, (wanted,), state, force=True, epoch="1700000001") + python_output = _serialize_plan_for_jq_parity(plan) + jq_output = _jq_plan_output(raw_board, wanted, cards_tsv, divergence_text) + self.assertEqual(jq_output, python_output) + + def test_config_boundary_is_opt_in_and_validates_board_identity(self) -> None: + """Return Disabled without inputs when absent and reject invalid config values.""" + with tempfile.TemporaryDirectory(prefix="helm-settings-") as temp: + home = Path(temp) + self.assertIsInstance(load_settings(home), Disabled) + config = home / "config" / "helm.json" + config.parent.mkdir() + config.write_text('{"owner":"fixture-owner","number":999}\n', encoding="utf-8") + settings = load_settings(home) + self.assertEqual(("fixture-owner", 999), (settings.default_board.owner, settings.default_board.number)) + config.write_text('{"owner":"","number":0}\n', encoding="utf-8") + with self.assertRaises(SettingsError): + load_settings(home) + config.unlink() + config.symlink_to("missing-helm-config.json") + with self.assertRaises(SettingsError): + load_settings(home) + + def test_routing_groups_complete_board_refs_in_stable_order(self) -> None: + """Route by project identity and include empty default and retention boards.""" + default = BoardRef(Owner("fixture-owner"), ProjectNumber(999)) + mapped = BoardRef(Owner("fixture-owner"), ProjectNumber(1000)) + route = board_for_project( + "sample", + {"projects": {"sample": {"owner": "fixture-owner", "number": 1000, "state": "migrating"}}}, + default, + ) + self.assertEqual(mapped, route) + card = DesiredCard( + TaskId("fixture-task"), + HomeId("main"), + mapped, + "Title", + "Body", + StatusName("Queued"), + KindName("ship"), + ProjectName("sample"), + PriorityName("P3"), + "3", + home_path=Path("/fixture/main"), + ) + order = route_cards((card,), default, retention_board=mapped) + self.assertEqual((mapped, default), tuple(group.board for group in order)) + self.assertEqual((card,), order[0].cards) + + def test_fleet_discovers_local_homes_and_rejects_duplicate_tasks(self) -> None: + """Union local backlogs once, skip remote homes, and reject ambiguous IDs.""" + with tempfile.TemporaryDirectory(prefix="helm-fleet-") as temp: + root = Path(temp) + main = root / "main" + mate = root / "mate" + remote = root / "remote" + for home in (main, mate): + (home / "data").mkdir(parents=True) + (main / "data" / "backlog.md").write_text( + "## Queued\n- [ ] fixture-main-task - Main work (repo: sample)\n", encoding="utf-8" + ) + (mate / "data" / "backlog.md").write_text( + "## Queued\n- [ ] fixture-mate-task - Mate work (repo: beta)\n", encoding="utf-8" + ) + (main / "data" / "projects.md").write_text("- sample - Main project\n", encoding="utf-8") + (mate / "data" / "projects.md").write_text("- beta - Mate project\n", encoding="utf-8") + registry = main / "data" / "secondmates.md" + registry.write_text( + f"- fixture-mate - Local fixture (home: {mate}; scope: fixture; projects: beta; added 2026-09-01)\n" + "- fixture-remote - Remote fixture (host: fixture-host; root: /fixture/root; home: /fixture/remote; scope: fixture; projects: gamma; added 2026-09-01)\n", + encoding="utf-8", + ) + homes = discover_local_homes(main, registry) + self.assertEqual(("main", "fixture-mate"), tuple(str(home.id) for home in homes)) + self.assertEqual(("sample", "beta"), registered_projects(homes)) + records = load_fleet(homes) + self.assertEqual(("fixture-main-task", "fixture-mate-task"), tuple(str(row.id) for row in records)) + self.assertFalse(remote.exists()) + (mate / "data" / "backlog.md").write_text( + "## Queued\n- [ ] fixture-main-task - Duplicate work (repo: beta)\n", encoding="utf-8" + ) + with self.assertRaises(FleetInputError): + load_fleet(homes) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/fm-helm-sync-python.test.sh b/tests/fm-helm-sync-python.test.sh new file mode 100755 index 00000000000..22795bd1115 --- /dev/null +++ b/tests/fm-helm-sync-python.test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -eu + +# Coverage: python/helm_sync/tests/jq_parity.py imports the whole +# python/helm_sync package, so a change to any of its source files, or to the +# parity suite itself, is proven here via this file's own literal references +# (the generic changed-file selector maps any path by scanning test sources +# for a literal reference to it): +# python/helm_sync/__init__.py +# python/helm_sync/backlog.py +# python/helm_sync/desired.py +# python/helm_sync/fleet.py +# python/helm_sync/model.py +# python/helm_sync/planner.py +# python/helm_sync/routing.py +# python/helm_sync/settings.py +# python/helm_sync/tests/jq_parity.py + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found"; exit 0; } +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } +if python3 -c "import pytest" >/dev/null 2>&1; then + python3 -m pytest "$ROOT/python/helm_sync/tests/jq_parity.py" +else + python3 "$ROOT/python/helm_sync/tests/jq_parity.py" +fi