diff --git a/project_setup/project.py b/project_setup/project.py index 858196b..7c1badf 100644 --- a/project_setup/project.py +++ b/project_setup/project.py @@ -170,17 +170,70 @@ def create_field(client: GitHubClient, project_id: str, field: dict) -> None: raise ValueError(f"Unsupported project field type: {field_type}") +def single_select_option_inputs(existing_field: dict, desired_field: dict) -> list[dict]: + existing_by_name = { + str(option.get("name") or "").casefold(): option + for option in existing_field.get("options", []) + if option.get("name") + } + result: list[dict] = [] + for desired_name in desired_field.get("options", []): + option = {"name": str(desired_name), "color": "GRAY", "description": ""} + current = existing_by_name.get(str(desired_name).casefold()) + if current and current.get("id"): + option["id"] = str(current["id"]) + result.append(option) + return result + + +def update_single_select_field(client: GitHubClient, existing_field: dict, desired_field: dict) -> None: + mutation = """ + mutation($field:ID!, $options:[ProjectV2SingleSelectFieldOptionInput!]!) { + updateProjectV2Field(input:{fieldId:$field,singleSelectOptions:$options}) { + projectV2Field { ... on ProjectV2SingleSelectField { id } } + } + } + """ + client.graphql( + mutation, + { + "field": existing_field["id"], + "options": single_select_option_inputs(existing_field, desired_field), + }, + ) + + +def single_select_options_match(existing_field: dict, desired_field: dict) -> bool: + existing_names = [str(option.get("name") or "") for option in existing_field.get("options", [])] + desired_names = [str(option) for option in desired_field.get("options", [])] + return existing_names == desired_names + + def ensure_fields(client: GitHubClient, project_id: str, definition: dict, dry_run: bool = False) -> dict[str, dict]: existing = {field["name"]: field for field in list_project_fields(client, project_id) if field.get("name")} + changed = False for field in definition.get("fields", []): - if field["name"] in existing: + existing_field = existing.get(field["name"]) + if existing_field: + if ( + field.get("type") == "single_select" + and existing_field.get("__typename") == "ProjectV2SingleSelectField" + and not single_select_options_match(existing_field, field) + ): + if dry_run: + print(f"[DRY-RUN] Would update field options: {field['name']}") + else: + update_single_select_field(client, existing_field, field) + changed = True + print(f"updated field options: {field['name']}") continue if dry_run: print(f"[DRY-RUN] Would create field: {field['name']} ({field['type']})") else: create_field(client, project_id, field) + changed = True print(f"created field: {field['name']}") - if dry_run: + if dry_run or not changed: return existing return {field["name"]: field for field in list_project_fields(client, project_id) if field.get("name")} diff --git a/tests/test_project_field_reconciliation.py b/tests/test_project_field_reconciliation.py new file mode 100644 index 0000000..eb26bfe --- /dev/null +++ b/tests/test_project_field_reconciliation.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from unittest import TestCase +from unittest.mock import Mock, patch + +from project_setup.project import ensure_fields, single_select_option_inputs, update_single_select_field + + +class ProjectFieldReconciliationTests(TestCase): + def test_option_inputs_preserve_matching_option_ids(self): + existing = { + "id": "FIELD", + "options": [ + {"id": "DONE-ID", "name": "Done"}, + {"id": "TODO-ID", "name": "Todo"}, + ], + } + desired = {"name": "Status", "type": "single_select", "options": ["In review", "Done"]} + + self.assertEqual( + single_select_option_inputs(existing, desired), + [ + {"name": "In review", "color": "GRAY", "description": ""}, + {"name": "Done", "color": "GRAY", "description": "", "id": "DONE-ID"}, + ], + ) + + def test_update_single_select_field_uses_project_v2_field_mutation(self): + client = Mock() + client.graphql.return_value = {"updateProjectV2Field": {"projectV2Field": {"id": "FIELD"}}} + existing = { + "id": "FIELD", + "options": [ + {"id": "TODO-ID", "name": "Todo"}, + {"id": "INPROGRESS-ID", "name": "In Progress"}, + {"id": "DONE-ID", "name": "Done"}, + ], + } + desired = {"name": "Status", "type": "single_select", "options": ["In progress", "In review", "Done"]} + + update_single_select_field(client, existing, desired) + + query, variables = client.graphql.call_args.args + self.assertIn("updateProjectV2Field", query) + self.assertEqual(variables["field"], "FIELD") + self.assertEqual([item["name"] for item in variables["options"]], ["In progress", "In review", "Done"]) + self.assertEqual(variables["options"][0]["id"], "INPROGRESS-ID") + self.assertEqual(variables["options"][2]["id"], "DONE-ID") + + @patch("project_setup.project.update_single_select_field") + @patch("project_setup.project.list_project_fields") + def test_ensure_fields_reconciles_builtin_status_options(self, list_fields, update_field): + initial = { + "__typename": "ProjectV2SingleSelectField", + "id": "STATUS-FIELD", + "name": "Status", + "dataType": "SINGLE_SELECT", + "options": [ + {"id": "TODO-ID", "name": "Todo"}, + {"id": "INPROGRESS-ID", "name": "In Progress"}, + {"id": "DONE-ID", "name": "Done"}, + ], + } + reconciled = { + **initial, + "options": [ + {"id": "NEW-PROGRESS", "name": "In progress"}, + {"id": "NEW-REVIEW", "name": "In review"}, + {"id": "DONE-ID", "name": "Done"}, + ], + } + list_fields.side_effect = [[initial], [reconciled]] + definition = { + "fields": [ + {"name": "Status", "type": "single_select", "options": ["In progress", "In review", "Done"]} + ] + } + + result = ensure_fields(Mock(), "PROJECT", definition, dry_run=False) + + update_field.assert_called_once_with(update_field.call_args.args[0], initial, definition["fields"][0]) + self.assertEqual(result["Status"]["options"][1]["name"], "In review") + + @patch("project_setup.project.update_single_select_field") + @patch("project_setup.project.list_project_fields") + def test_ensure_fields_keeps_matching_status_idempotent(self, list_fields, update_field): + current = { + "__typename": "ProjectV2SingleSelectField", + "id": "STATUS-FIELD", + "name": "Status", + "dataType": "SINGLE_SELECT", + "options": [ + {"id": "PROGRESS", "name": "In progress"}, + {"id": "REVIEW", "name": "In review"}, + {"id": "DONE", "name": "Done"}, + ], + } + list_fields.return_value = [current] + definition = { + "fields": [ + {"name": "Status", "type": "single_select", "options": ["In progress", "In review", "Done"]} + ] + } + + result = ensure_fields(Mock(), "PROJECT", definition, dry_run=False) + + update_field.assert_not_called() + self.assertEqual(result["Status"], current)