From 99c899b8d3d373cf472c0eb86ebefa39a1f5e1cf Mon Sep 17 00:00:00 2001 From: Ilanit Stein Date: Fri, 10 Apr 2026 19:11:27 +0300 Subject: [PATCH 1/7] Support Single Select field mapping for storypoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the issue where GitHub Project Single Select fields with numeric option mappings failed to sync storypoints to Jira. Previously, sync2jira only supported Number-type fields for storypoints, attempting to read item["number"]. When a Single Select field was used with an options mapping (e.g., "🐇 Small": 5), it would throw a KeyError. This change: - Checks if storypoints config has an options mapping - For Single Select fields: reads item["name"] and maps via options - For Number fields: reads item["number"] directly (existing behavior) - Ensures the final value is always an int for Jira compatibility Resolves the "Error while processing storypoints: 'number'" error when using Single Select fields with numeric mappings. Co-Authored-By: Claude Sonnet 4.5 --- sync2jira/upstream_issue.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sync2jira/upstream_issue.py b/sync2jira/upstream_issue.py index 73cbebd3..d8592a0b 100644 --- a/sync2jira/upstream_issue.py +++ b/sync2jira/upstream_issue.py @@ -292,7 +292,16 @@ def add_project_values(issue, upstream, headers, config): sp_field = github_project_fields.get("storypoints", {}).get("gh_field") if gh_field_name == sp_field: try: - issue["storypoints"] = int(item["number"]) + # Check if there's an options mapping (for Single Select fields) + sp_options = github_project_fields.get("storypoints", {}).get("options") + if sp_options: + # Single Select field - get name and map it + sp_value = item.get("name") + if sp_value and sp_value in sp_options: + issue["storypoints"] = int(sp_options[sp_value]) + else: + # Number field - get number directly + issue["storypoints"] = int(item["number"]) except (ValueError, KeyError) as err: log.info( "Error while processing storypoints for issue %s/%s#%s: %s", From d9677955b8f6f593d297969ea1c8201a661ccff2 Mon Sep 17 00:00:00 2001 From: Ilanit Stein Date: Fri, 10 Apr 2026 19:27:20 +0300 Subject: [PATCH 2/7] Add TypeError handling and better error logging for storypoints Addresses code review feedback: - Catch TypeError in addition to ValueError and KeyError to handle misconfigured mappings (e.g., None or non-numeric values) - Store mapped_value in intermediate variable for clearer error context - Add explicit log message when storypoints value not found in options - Prevents crashes when options mapping contains invalid values Co-Authored-By: Claude Sonnet 4.5 --- sync2jira/upstream_issue.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/sync2jira/upstream_issue.py b/sync2jira/upstream_issue.py index d8592a0b..71d8e0ea 100644 --- a/sync2jira/upstream_issue.py +++ b/sync2jira/upstream_issue.py @@ -298,11 +298,20 @@ def add_project_values(issue, upstream, headers, config): # Single Select field - get name and map it sp_value = item.get("name") if sp_value and sp_value in sp_options: - issue["storypoints"] = int(sp_options[sp_value]) + mapped_value = sp_options[sp_value] + issue["storypoints"] = int(mapped_value) + else: + log.info( + "Storypoints value '%s' not found in options mapping for issue %s/%s#%s", + sp_value, + orgname, + reponame, + issuenumber, + ) else: # Number field - get number directly issue["storypoints"] = int(item["number"]) - except (ValueError, KeyError) as err: + except (ValueError, TypeError, KeyError) as err: log.info( "Error while processing storypoints for issue %s/%s#%s: %s", orgname, From 3e0822f123d9054224ce422ae1b7402aa994ede7 Mon Sep 17 00:00:00 2001 From: Ilanit Stein Date: Sun, 12 Apr 2026 09:38:33 +0300 Subject: [PATCH 3/7] Refactor storypoints processing per code review Move logic that cannot fail outside try blocks and separate distinct error cases: missing Single Select name (warning), unmapped value (info), and conversion errors (info). Use walrus operator with 'is None' check to properly handle zero as a valid story point value. Co-Authored-By: Claude Sonnet 4.5 --- sync2jira/upstream_issue.py | 60 +++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/sync2jira/upstream_issue.py b/sync2jira/upstream_issue.py index 71d8e0ea..30e6d82f 100644 --- a/sync2jira/upstream_issue.py +++ b/sync2jira/upstream_issue.py @@ -291,34 +291,50 @@ def add_project_values(issue, upstream, headers, config): continue sp_field = github_project_fields.get("storypoints", {}).get("gh_field") if gh_field_name == sp_field: - try: - # Check if there's an options mapping (for Single Select fields) - sp_options = github_project_fields.get("storypoints", {}).get("options") - if sp_options: - # Single Select field - get name and map it - sp_value = item.get("name") - if sp_value and sp_value in sp_options: - mapped_value = sp_options[sp_value] - issue["storypoints"] = int(mapped_value) - else: + # Check if there's an options mapping (for Single Select fields); if + # so, convert... + sp_options = github_project_fields.get("storypoints", {}).get("options") + if sp_options: + # Single Select field - get name and map it + sp_value = item.get("name") + if not sp_value: + log.warning( + "No Single Select name found for storypoints options in message for issue %s/%s#%s", + orgname, + reponame, + issuenumber, + ) + elif (sp_number := sp_options.get(sp_value)) is None: + log.info( + "Storypoints value '%s' not found in options mapping for issue %s/%s#%s", + sp_value, + orgname, + reponame, + issuenumber, + ) + else: + try: + issue["storypoints"] = int(sp_number) + except (ValueError, TypeError) as err: log.info( - "Storypoints value '%s' not found in options mapping for issue %s/%s#%s", - sp_value, + "Error while processing storypoints for issue %s/%s#%s: %s", orgname, reponame, issuenumber, + err, ) - else: - # Number field - get number directly + else: + # Number field - get number directly + try: issue["storypoints"] = int(item["number"]) - except (ValueError, TypeError, KeyError) as err: - log.info( - "Error while processing storypoints for issue %s/%s#%s: %s", - orgname, - reponame, - issuenumber, - err, - ) + except (ValueError, TypeError, KeyError) as err: + log.info( + "Error while processing storypoints for issue %s/%s#%s: %s", + orgname, + reponame, + issuenumber, + err, + ) continue From eb3786b60b6f217bbfa88961ff8cb085b4517088 Mon Sep 17 00:00:00 2001 From: Ilanit Stein Date: Tue, 14 Apr 2026 20:42:21 +0300 Subject: [PATCH 4/7] Improve storypoints error logging and add test coverage Address code review feedback for PR #452: - Make error log messages distinct between Single Select and Number field paths - Include the actual value that failed conversion in log messages for easier debugging - Add comprehensive test coverage for all error handling scenarios Test coverage includes: - Single Select field with invalid mapped value - Number field with invalid number value - Single Select field with missing name - Single Select field with unmapped value All tests pass with 82.58% coverage (exceeds 70% requirement). Co-Authored-By: Claude Sonnet 4.5 --- sync2jira/upstream_issue.py | 6 +- tests/test_upstream_issue.py | 178 +++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/sync2jira/upstream_issue.py b/sync2jira/upstream_issue.py index 30e6d82f..b2487244 100644 --- a/sync2jira/upstream_issue.py +++ b/sync2jira/upstream_issue.py @@ -317,7 +317,8 @@ def add_project_values(issue, upstream, headers, config): issue["storypoints"] = int(sp_number) except (ValueError, TypeError) as err: log.info( - "Error while processing storypoints for issue %s/%s#%s: %s", + "Error converting Single Select storypoints value '%s' to int for issue %s/%s#%s: %s", + sp_number, orgname, reponame, issuenumber, @@ -329,7 +330,8 @@ def add_project_values(issue, upstream, headers, config): issue["storypoints"] = int(item["number"]) except (ValueError, TypeError, KeyError) as err: log.info( - "Error while processing storypoints for issue %s/%s#%s: %s", + "Error converting Number field storypoints value '%s' to int for issue %s/%s#%s: %s", + item.get("number", "missing"), orgname, reponame, issuenumber, diff --git a/tests/test_upstream_issue.py b/tests/test_upstream_issue.py index 17ec8e4d..c249be83 100644 --- a/tests/test_upstream_issue.py +++ b/tests/test_upstream_issue.py @@ -768,6 +768,184 @@ def test_add_project_values_early_exit(self, mock_requests_post): # Reset mock mock_requests_post.reset_mock() + @mock.patch(PATH + "requests.post") + def test_add_project_values_storypoints_error_handling(self, mock_requests_post): + """ + Test 'add_project_values' error handling for storypoints conversion failures. + Tests both Single Select (Size) and Number (Estimate) field error paths. + """ + # Set up base config + upstream_config = { + "issue_updates": ["github_project_fields"], + "github_project_number": 1, + } + self.mock_config["sync2jira"]["map"]["github"]["org/repo"] = upstream_config + + mock_issue = { + "number": 1234, + "storypoints": None, + "priority": None, + } + + # Test case 1: Single Select field (Size) with invalid mapped value (not convertible to int) + upstream_config["github_project_fields"] = { + "storypoints": { + "gh_field": "Size", + "options": {"Small": "not_a_number"}, + } + } + mock_requests_post.return_value.json.return_value = { + "data": { + "repository": { + "issue": { + "projectItems": { + "nodes": [ + { + "project": {"title": "Project 1", "number": 1}, + "fieldValues": { + "nodes": [ + { + "fieldName": {"name": "Size"}, + "name": "Small", + } + ] + }, + } + ] + } + } + } + } + } + u.add_project_values( + issue=mock_issue, + upstream="org/repo", + headers={}, + config=self.mock_config, + ) + # Storypoints should not be set due to conversion error + self.assertIsNone(mock_issue.get("storypoints")) + mock_requests_post.reset_mock() + + # Test case 2: Number field (Estimate) with invalid number value + upstream_config["github_project_fields"] = { + "storypoints": {"gh_field": "Estimate"} + } + mock_requests_post.return_value.json.return_value = { + "data": { + "repository": { + "issue": { + "projectItems": { + "nodes": [ + { + "project": {"title": "Project 1", "number": 1}, + "fieldValues": { + "nodes": [ + { + "fieldName": {"name": "Estimate"}, + "number": "invalid", + } + ] + }, + } + ] + } + } + } + } + } + mock_issue["storypoints"] = None + u.add_project_values( + issue=mock_issue, + upstream="org/repo", + headers={}, + config=self.mock_config, + ) + # Storypoints should not be set due to conversion error + self.assertIsNone(mock_issue.get("storypoints")) + mock_requests_post.reset_mock() + + # Test case 3: Single Select field (Size) with missing name + upstream_config["github_project_fields"] = { + "storypoints": { + "gh_field": "Size", + "options": {"Small": 5}, + } + } + mock_requests_post.return_value.json.return_value = { + "data": { + "repository": { + "issue": { + "projectItems": { + "nodes": [ + { + "project": {"title": "Project 1", "number": 1}, + "fieldValues": { + "nodes": [ + { + "fieldName": {"name": "Size"}, + # name is missing + } + ] + }, + } + ] + } + } + } + } + } + mock_issue["storypoints"] = None + u.add_project_values( + issue=mock_issue, + upstream="org/repo", + headers={}, + config=self.mock_config, + ) + # Storypoints should not be set due to missing name + self.assertIsNone(mock_issue.get("storypoints")) + mock_requests_post.reset_mock() + + # Test case 4: Single Select field (Size) with value not in options mapping + upstream_config["github_project_fields"] = { + "storypoints": { + "gh_field": "Size", + "options": {"Small": 5, "Medium": 8}, + } + } + mock_requests_post.return_value.json.return_value = { + "data": { + "repository": { + "issue": { + "projectItems": { + "nodes": [ + { + "project": {"title": "Project 1", "number": 1}, + "fieldValues": { + "nodes": [ + { + "fieldName": {"name": "Size"}, + "name": "Large", # Not in options + } + ] + }, + } + ] + } + } + } + } + } + mock_issue["storypoints"] = None + u.add_project_values( + issue=mock_issue, + upstream="org/repo", + headers={}, + config=self.mock_config, + ) + # Storypoints should not be set due to unmapped value + self.assertIsNone(mock_issue.get("storypoints")) + def test_passes_github_filters(self): """ Test passes_github_filters for labels, milestone, and other fields. From 1b9762c2a242c26f4e966b96f0ca64a96c608572 Mon Sep 17 00:00:00 2001 From: Ilanit Stein Date: Tue, 14 Apr 2026 22:12:36 +0300 Subject: [PATCH 5/7] Refactor storypoints code and convert tests to table-driven Address code review feedback: Code optimization: - Cache storypoints dict in local variable to avoid duplicate lookups - Use walrus operator for options check - More efficient dict access pattern Test improvements: - Convert to table-driven test using subTest for better clarity - Add missing test scenarios (no storypoints in config, no gh_field, empty options) - Reduced boilerplate by consolidating 7 scenarios into one focused test Test scenarios now cover: 1. No storypoints in config 2. No gh_field in storypoints 3. Empty options dict 4. Single Select missing name 5. Single Select unmapped value 6. Single Select invalid mapped value 7. Number field invalid value All tests pass and meet coverage requirements. Co-Authored-By: Claude Sonnet 4.5 --- sync2jira/upstream_issue.py | 6 +- tests/test_upstream_issue.py | 228 ++++++++++++----------------------- 2 files changed, 77 insertions(+), 157 deletions(-) diff --git a/sync2jira/upstream_issue.py b/sync2jira/upstream_issue.py index b2487244..1e66a693 100644 --- a/sync2jira/upstream_issue.py +++ b/sync2jira/upstream_issue.py @@ -289,12 +289,12 @@ def add_project_values(issue, upstream, headers, config): if gh_field_name == prio_field: issue["priority"] = item.get("name") continue - sp_field = github_project_fields.get("storypoints", {}).get("gh_field") + sp_dict = github_project_fields.get("storypoints", {}) + sp_field = sp_dict.get("gh_field") if gh_field_name == sp_field: # Check if there's an options mapping (for Single Select fields); if # so, convert... - sp_options = github_project_fields.get("storypoints", {}).get("options") - if sp_options: + if sp_options := sp_dict.get("options"): # Single Select field - get name and map it sp_value = item.get("name") if not sp_value: diff --git a/tests/test_upstream_issue.py b/tests/test_upstream_issue.py index c249be83..f70da90c 100644 --- a/tests/test_upstream_issue.py +++ b/tests/test_upstream_issue.py @@ -771,8 +771,8 @@ def test_add_project_values_early_exit(self, mock_requests_post): @mock.patch(PATH + "requests.post") def test_add_project_values_storypoints_error_handling(self, mock_requests_post): """ - Test 'add_project_values' error handling for storypoints conversion failures. - Tests both Single Select (Size) and Number (Estimate) field error paths. + Test 'add_project_values' error handling for storypoints. + Table-driven test covering configuration errors and conversion failures. """ # Set up base config upstream_config = { @@ -787,164 +787,84 @@ def test_add_project_values_storypoints_error_handling(self, mock_requests_post) "priority": None, } - # Test case 1: Single Select field (Size) with invalid mapped value (not convertible to int) - upstream_config["github_project_fields"] = { - "storypoints": { - "gh_field": "Size", - "options": {"Small": "not_a_number"}, - } - } - mock_requests_post.return_value.json.return_value = { - "data": { - "repository": { - "issue": { - "projectItems": { - "nodes": [ - { - "project": {"title": "Project 1", "number": 1}, - "fieldValues": { - "nodes": [ - { - "fieldName": {"name": "Size"}, - "name": "Small", - } - ] - }, - } - ] - } - } - } - } - } - u.add_project_values( - issue=mock_issue, - upstream="org/repo", - headers={}, - config=self.mock_config, + # Test scenarios: (description, github_project_fields, field_values_nodes) + scenarios = ( + # Test 1: No "storypoints" field in github_project_fields + ( + "No storypoints in config", + {"priority": {"gh_field": "Priority"}}, + [{"fieldName": {"name": "Size"}, "name": "Small"}], + ), + # Test 2: No "gh_field" in storypoints config + ( + "No gh_field in storypoints", + {"storypoints": {"options": {"Small": 5}}}, + [{"fieldName": {"name": "Size"}, "name": "Small"}], + ), + # Test 3: Empty options dict (Number field path with no value) + ( + "Empty options dict", + {"storypoints": {"gh_field": "Size", "options": {}}}, + [{"fieldName": {"name": "Size"}, "number": "invalid"}], + ), + # Test 4: Single Select - no "name" in item + ( + "Single Select missing name", + {"storypoints": {"gh_field": "Size", "options": {"Small": 5}}}, + [{"fieldName": {"name": "Size"}}], + ), + # Test 5: Single Select - value not in options mapping + ( + "Single Select unmapped value", + {"storypoints": {"gh_field": "Size", "options": {"Small": 5, "Medium": 8}}}, + [{"fieldName": {"name": "Size"}, "name": "Large"}], + ), + # Test 6: Single Select - ValueError converting mapped value + ( + "Single Select invalid mapped value", + {"storypoints": {"gh_field": "Size", "options": {"Small": "not_a_number"}}}, + [{"fieldName": {"name": "Size"}, "name": "Small"}], + ), + # Test 7: Number field - ValueError from invalid number + ( + "Number field invalid value", + {"storypoints": {"gh_field": "Estimate"}}, + [{"fieldName": {"name": "Estimate"}, "number": "invalid"}], + ), ) - # Storypoints should not be set due to conversion error - self.assertIsNone(mock_issue.get("storypoints")) - mock_requests_post.reset_mock() - # Test case 2: Number field (Estimate) with invalid number value - upstream_config["github_project_fields"] = { - "storypoints": {"gh_field": "Estimate"} - } - mock_requests_post.return_value.json.return_value = { - "data": { - "repository": { - "issue": { - "projectItems": { - "nodes": [ - { - "project": {"title": "Project 1", "number": 1}, - "fieldValues": { - "nodes": [ - { - "fieldName": {"name": "Estimate"}, - "number": "invalid", - } - ] - }, + for description, gpf, field_nodes in scenarios: + with self.subTest(description=description): + upstream_config["github_project_fields"] = gpf + mock_issue["storypoints"] = None + + mock_requests_post.return_value.json.return_value = { + "data": { + "repository": { + "issue": { + "projectItems": { + "nodes": [ + { + "project": {"title": "Project 1", "number": 1}, + "fieldValues": {"nodes": field_nodes}, + } + ] } - ] + } } } } - } - } - mock_issue["storypoints"] = None - u.add_project_values( - issue=mock_issue, - upstream="org/repo", - headers={}, - config=self.mock_config, - ) - # Storypoints should not be set due to conversion error - self.assertIsNone(mock_issue.get("storypoints")) - mock_requests_post.reset_mock() - - # Test case 3: Single Select field (Size) with missing name - upstream_config["github_project_fields"] = { - "storypoints": { - "gh_field": "Size", - "options": {"Small": 5}, - } - } - mock_requests_post.return_value.json.return_value = { - "data": { - "repository": { - "issue": { - "projectItems": { - "nodes": [ - { - "project": {"title": "Project 1", "number": 1}, - "fieldValues": { - "nodes": [ - { - "fieldName": {"name": "Size"}, - # name is missing - } - ] - }, - } - ] - } - } - } - } - } - mock_issue["storypoints"] = None - u.add_project_values( - issue=mock_issue, - upstream="org/repo", - headers={}, - config=self.mock_config, - ) - # Storypoints should not be set due to missing name - self.assertIsNone(mock_issue.get("storypoints")) - mock_requests_post.reset_mock() - - # Test case 4: Single Select field (Size) with value not in options mapping - upstream_config["github_project_fields"] = { - "storypoints": { - "gh_field": "Size", - "options": {"Small": 5, "Medium": 8}, - } - } - mock_requests_post.return_value.json.return_value = { - "data": { - "repository": { - "issue": { - "projectItems": { - "nodes": [ - { - "project": {"title": "Project 1", "number": 1}, - "fieldValues": { - "nodes": [ - { - "fieldName": {"name": "Size"}, - "name": "Large", # Not in options - } - ] - }, - } - ] - } - } - } - } - } - mock_issue["storypoints"] = None - u.add_project_values( - issue=mock_issue, - upstream="org/repo", - headers={}, - config=self.mock_config, - ) - # Storypoints should not be set due to unmapped value - self.assertIsNone(mock_issue.get("storypoints")) + + u.add_project_values( + issue=mock_issue, + upstream="org/repo", + headers={}, + config=self.mock_config, + ) + + # Storypoints should not be set in any error scenario + self.assertIsNone(mock_issue.get("storypoints")) + mock_requests_post.reset_mock() def test_passes_github_filters(self): """ From 892e7bfe16bd6f804ae2f921dcfc37347edf09b5 Mon Sep 17 00:00:00 2001 From: Ilanit Stein Date: Tue, 14 Apr 2026 22:59:24 +0300 Subject: [PATCH 6/7] Fix Black formatting for test file Apply Black code formatting to test_upstream_issue.py to pass CI checks. Reformats nested dictionaries in test scenarios for better readability. Co-Authored-By: Claude Sonnet 4.5 --- tests/test_upstream_issue.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/test_upstream_issue.py b/tests/test_upstream_issue.py index f70da90c..8d651d6e 100644 --- a/tests/test_upstream_issue.py +++ b/tests/test_upstream_issue.py @@ -816,13 +816,23 @@ def test_add_project_values_storypoints_error_handling(self, mock_requests_post) # Test 5: Single Select - value not in options mapping ( "Single Select unmapped value", - {"storypoints": {"gh_field": "Size", "options": {"Small": 5, "Medium": 8}}}, + { + "storypoints": { + "gh_field": "Size", + "options": {"Small": 5, "Medium": 8}, + } + }, [{"fieldName": {"name": "Size"}, "name": "Large"}], ), # Test 6: Single Select - ValueError converting mapped value ( "Single Select invalid mapped value", - {"storypoints": {"gh_field": "Size", "options": {"Small": "not_a_number"}}}, + { + "storypoints": { + "gh_field": "Size", + "options": {"Small": "not_a_number"}, + } + }, [{"fieldName": {"name": "Size"}, "name": "Small"}], ), # Test 7: Number field - ValueError from invalid number @@ -845,7 +855,10 @@ def test_add_project_values_storypoints_error_handling(self, mock_requests_post) "projectItems": { "nodes": [ { - "project": {"title": "Project 1", "number": 1}, + "project": { + "title": "Project 1", + "number": 1, + }, "fieldValues": {"nodes": field_nodes}, } ] From 682541f17ca28ecc388ed3f147ec2a5db4afe32a Mon Sep 17 00:00:00 2001 From: Ilanit Stein Date: Wed, 15 Apr 2026 21:06:59 +0300 Subject: [PATCH 7/7] Fix storypoints tests to actually exercise the new code paths The tests were not setting mock_requests_post.return_value.status_code to 200, causing the function to return early at the HTTP status check before reaching any storypoints logic. Tests passed vacuously because storypoints was initialized to None and never changed. Changes: - Set status_code = 200 on the mock so execution reaches the storypoints code - Add a Priority field as a canary to every scenario to prove the field-processing loop was entered - Add success-path scenarios (Single Select and Number field) to cover lines that Coveralls flagged as uncovered - Add KeyError scenario for missing "number" key in Number field path Co-Authored-By: Claude Opus 4.6 --- tests/test_upstream_issue.py | 176 ++++++++++++++++++++++++++++++----- 1 file changed, 153 insertions(+), 23 deletions(-) diff --git a/tests/test_upstream_issue.py b/tests/test_upstream_issue.py index 8d651d6e..abc58b62 100644 --- a/tests/test_upstream_issue.py +++ b/tests/test_upstream_issue.py @@ -769,10 +769,14 @@ def test_add_project_values_early_exit(self, mock_requests_post): mock_requests_post.reset_mock() @mock.patch(PATH + "requests.post") - def test_add_project_values_storypoints_error_handling(self, mock_requests_post): + def test_add_project_values_storypoints(self, mock_requests_post): """ - Test 'add_project_values' error handling for storypoints. - Table-driven test covering configuration errors and conversion failures. + Test 'add_project_values' storypoints processing. + Table-driven test covering both error paths and success paths. + + Every scenario includes a Priority field as a canary: if the + function returns early (e.g. because of a missing status_code + mock), priority would not be set and the test would fail. """ # Set up base config upstream_config = { @@ -787,66 +791,179 @@ def test_add_project_values_storypoints_error_handling(self, mock_requests_post) "priority": None, } - # Test scenarios: (description, github_project_fields, field_values_nodes) + # Ensure the mock HTTP response indicates success so the function + # proceeds past the status_code check. + mock_requests_post.return_value.status_code = 200 + + # Each scenario: (description, github_project_fields, + # field_values_nodes, expected_storypoints) + # All scenarios include a Priority node so we can assert + # priority == "High" as proof we entered the field-processing loop. scenarios = ( - # Test 1: No "storypoints" field in github_project_fields + # --- Error scenarios: storypoints should remain None --- + # Test 1: No "storypoints" key in github_project_fields ( "No storypoints in config", {"priority": {"gh_field": "Priority"}}, - [{"fieldName": {"name": "Size"}, "name": "Small"}], + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Size"}, "name": "Small"}, + ], + None, ), # Test 2: No "gh_field" in storypoints config ( "No gh_field in storypoints", - {"storypoints": {"options": {"Small": 5}}}, - [{"fieldName": {"name": "Size"}, "name": "Small"}], + { + "priority": {"gh_field": "Priority"}, + "storypoints": {"options": {"Small": 5}}, + }, + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Size"}, "name": "Small"}, + ], + None, ), - # Test 3: Empty options dict (Number field path with no value) + # Test 3: Empty options dict falls through to Number field path ( - "Empty options dict", - {"storypoints": {"gh_field": "Size", "options": {}}}, - [{"fieldName": {"name": "Size"}, "number": "invalid"}], + "Empty options dict with invalid number", + { + "priority": {"gh_field": "Priority"}, + "storypoints": {"gh_field": "Size", "options": {}}, + }, + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Size"}, "number": "invalid"}, + ], + None, ), # Test 4: Single Select - no "name" in item ( "Single Select missing name", - {"storypoints": {"gh_field": "Size", "options": {"Small": 5}}}, - [{"fieldName": {"name": "Size"}}], + { + "priority": {"gh_field": "Priority"}, + "storypoints": { + "gh_field": "Size", + "options": {"Small": 5}, + }, + }, + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Size"}}, + ], + None, ), # Test 5: Single Select - value not in options mapping ( "Single Select unmapped value", { + "priority": {"gh_field": "Priority"}, "storypoints": { "gh_field": "Size", "options": {"Small": 5, "Medium": 8}, - } + }, }, - [{"fieldName": {"name": "Size"}, "name": "Large"}], + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Size"}, "name": "Large"}, + ], + None, ), # Test 6: Single Select - ValueError converting mapped value ( "Single Select invalid mapped value", { + "priority": {"gh_field": "Priority"}, "storypoints": { "gh_field": "Size", "options": {"Small": "not_a_number"}, - } + }, }, - [{"fieldName": {"name": "Size"}, "name": "Small"}], + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Size"}, "name": "Small"}, + ], + None, ), # Test 7: Number field - ValueError from invalid number ( "Number field invalid value", - {"storypoints": {"gh_field": "Estimate"}}, - [{"fieldName": {"name": "Estimate"}, "number": "invalid"}], + { + "priority": {"gh_field": "Priority"}, + "storypoints": {"gh_field": "Estimate"}, + }, + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Estimate"}, "number": "invalid"}, + ], + None, + ), + # Test 8: Number field - KeyError from missing "number" key + ( + "Number field missing number key", + { + "priority": {"gh_field": "Priority"}, + "storypoints": {"gh_field": "Estimate"}, + }, + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Estimate"}}, + ], + None, + ), + # --- Success scenarios: storypoints should be set --- + # Test 9: Single Select - valid mapping + ( + "Single Select valid mapping", + { + "priority": {"gh_field": "Priority"}, + "storypoints": { + "gh_field": "Size", + "options": {"Small": 1, "Medium": 3, "Large": 8}, + }, + }, + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Size"}, "name": "Medium"}, + ], + 3, + ), + # Test 10: Single Select - mapped value is a string int + ( + "Single Select string mapped value", + { + "priority": {"gh_field": "Priority"}, + "storypoints": { + "gh_field": "Size", + "options": {"Small": "2"}, + }, + }, + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Size"}, "name": "Small"}, + ], + 2, + ), + # Test 11: Number field - valid number + ( + "Number field valid value", + { + "priority": {"gh_field": "Priority"}, + "storypoints": {"gh_field": "Estimate"}, + }, + [ + {"fieldName": {"name": "Priority"}, "name": "High"}, + {"fieldName": {"name": "Estimate"}, "number": 5}, + ], + 5, ), ) - for description, gpf, field_nodes in scenarios: + for description, gpf, field_nodes, expected_sp in scenarios: with self.subTest(description=description): upstream_config["github_project_fields"] = gpf mock_issue["storypoints"] = None + mock_issue["priority"] = None mock_requests_post.return_value.json.return_value = { "data": { @@ -875,8 +992,21 @@ def test_add_project_values_storypoints_error_handling(self, mock_requests_post) config=self.mock_config, ) - # Storypoints should not be set in any error scenario - self.assertIsNone(mock_issue.get("storypoints")) + # Priority must be set in every scenario — this is + # our canary that the function actually reached the + # field-processing loop rather than returning early. + self.assertEqual( + mock_issue["priority"], + "High", + f"{description}: priority was not set — function " + f"may have returned early", + ) + self.assertEqual( + mock_issue.get("storypoints"), + expected_sp, + f"{description}: expected storypoints={expected_sp}, " + f"got {mock_issue.get('storypoints')}", + ) mock_requests_post.reset_mock() def test_passes_github_filters(self):