Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions project_setup/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")}

Expand Down
108 changes: 108 additions & 0 deletions tests/test_project_field_reconciliation.py
Original file line number Diff line number Diff line change
@@ -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)
Loading