codee-agent 0.6.3__tar.gz → 0.6.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {codee_agent-0.6.3 → codee_agent-0.6.4}/PKG-INFO +1 -1
  2. {codee_agent-0.6.3 → codee_agent-0.6.4}/pyproject.toml +1 -1
  3. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/executor.py +12 -8
  4. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_executor.py +84 -0
  5. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/provider.py +1 -0
  6. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/test.py +5 -0
  7. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_abstract/provider.py +29 -8
  8. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_azure_devops/provider.py +13 -99
  9. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_azure_devops/test.py +38 -97
  10. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_jira/provider.py +24 -70
  11. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_jira/test.py +49 -24
  12. {codee_agent-0.6.3 → codee_agent-0.6.4}/LICENSE +0 -0
  13. {codee_agent-0.6.3 → codee_agent-0.6.4}/README.md +0 -0
  14. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/.gitignore +0 -0
  15. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/__init__.py +0 -0
  16. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/admin.py +0 -0
  17. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/admin_api.py +0 -0
  18. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/admin_cli.py +0 -0
  19. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/admin_service.py +0 -0
  20. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/agent_cli.py +0 -0
  21. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/coding_agents.py +0 -0
  22. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/init_cli.py +0 -0
  23. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/__init__.py +0 -0
  24. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/claude_key_rotation.py +0 -0
  25. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/cron_describe.py +0 -0
  26. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/mcp_config.py +0 -0
  27. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/runs_db.py +0 -0
  28. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_claude_key_rotation.py +0 -0
  29. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_mcp_config.py +0 -0
  30. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_runs_db.py +0 -0
  31. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  32. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  33. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  34. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/trigger_cron_skills.py +0 -0
  35. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/trigger_email_skills.py +0 -0
  36. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/trigger_issue_skills.py +0 -0
  37. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/mail_server.py +0 -0
  38. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/setup_wizard.py +0 -0
  39. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/start_cli.py +0 -0
  40. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/tasks_providers.py +0 -0
  41. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/AGENTS.md +0 -0
  42. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/CLAUDE.md +0 -0
  43. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  44. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  45. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  46. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  47. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  48. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  49. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  50. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  51. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  52. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  53. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  54. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  55. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_admin_api.py +0 -0
  56. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_admin_cli.py +0 -0
  57. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_admin_service.py +0 -0
  58. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_agent_cli.py +0 -0
  59. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_coding_agents.py +0 -0
  60. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_init_cli.py +0 -0
  61. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_memory_index.py +0 -0
  62. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_setup_wizard.py +0 -0
  63. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_start_cli.py +0 -0
  64. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/workflow_graph.py +0 -0
  65. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_admin/__init__.py +0 -0
  66. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_admin/codee_admin.py +0 -0
  67. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_abstract/__init__.py +0 -0
  68. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_abstract/provider.py +0 -0
  69. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/__init__.py +0 -0
  70. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/account.py +0 -0
  71. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/credentials.py +0 -0
  72. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/oauth.py +0 -0
  73. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/usage.py +0 -0
  74. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_codex/__init__.py +0 -0
  75. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_codex/provider.py +0 -0
  76. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_codex/test.py +0 -0
  77. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_github_copilot/__init__.py +0 -0
  78. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_github_copilot/provider.py +0 -0
  79. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_github_copilot/test.py +0 -0
  80. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_database/__init__.py +0 -0
  81. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_database/claude_code_accounts.py +0 -0
  82. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_database/database.py +0 -0
  83. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_database/oauth_tokens.py +0 -0
  84. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/__init__.py +0 -0
  85. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/context.py +0 -0
  86. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/logging.py +0 -0
  87. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/test_context.py +0 -0
  88. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/test_logging.py +0 -0
  89. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_abstract/__init__.py +0 -0
  90. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_azure_devops/__init__.py +0 -0
  91. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_azure_devops/oauth.py +0 -0
  92. {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_jira/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.6.3
3
+ Version: 0.6.4
4
4
  Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
5
5
  Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,codex,automation
6
6
  Author: Denis Kibalko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.6.3"
3
+ version = "0.6.4"
4
4
  description = "A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -11,8 +11,8 @@ from codee_agent_claude_code.provider import ClaudeCodeAgent
11
11
  from codee_agent_codex.provider import CodexAgent
12
12
  from codee_agent_github_copilot.provider import GitHubCopilotAgent
13
13
  from codee_main_context.context import (
14
- CodeeMainContext, CodingAgent, STORY_ISSUE_TYPE, Settings,
15
- codee_issue_types, data_dir, load_settings, project_root)
14
+ CodeeMainContext, CodingAgent, Settings, codee_issue_types, data_dir,
15
+ load_settings, project_root)
16
16
  from codee_main_context.logging import configure_logging, get_logger
17
17
  from codee_tasks_abstract.provider import AbstractTasksProvider
18
18
 
@@ -424,12 +424,16 @@ def run_once() -> None:
424
424
  log.info("Incoming %s (%s, %s, %s): %s",
425
425
  task_id, status, issue_type, priority, summary)
426
426
 
427
- # Children of a Codee-owned story are driven by that story's own agent
428
- # run. What marks a story as Codee-owned is the provider's business.
429
- if (issue_type.casefold() != STORY_ISSUE_TYPE
430
- and task.is_parent_codee_story):
431
- log.debug("Skipping %s: parent %s is a Codee story",
432
- task_id, task.parent.key)
427
+ # An item whose parent is one Codee polls in its own right is left
428
+ # alone: the parent's own run is what decides what its children need,
429
+ # and picking the child up here as well is two agents on one change.
430
+ # Its type is what says so — a work item selected by a query of the
431
+ # user's own shields nothing, since nothing in a parent says whether
432
+ # that condition would have claimed it.
433
+ if task.is_parent_codee_work_item:
434
+ log.debug("Skipping %s: its parent %s is a %s, which Codee polls "
435
+ "in its own right",
436
+ task_id, task.parent.key, task.parent.work_item_type)
433
437
  continue
434
438
 
435
439
  skill = match_issue_skill(issue_skills, status, issue_type)
@@ -12,8 +12,11 @@ from codee_main_context.context import (
12
12
  from codee_tasks_azure_devops.provider import AzureDevOpsTasksProvider
13
13
  from codee_tasks_jira.provider import JiraTasksProvider
14
14
 
15
+ from codee_tasks_abstract.provider import Task
16
+
15
17
  from codee import executor, tasks_providers
16
18
  from codee.lib import runs_db
19
+ from codee.lib.trigger_issue_skills import IssueTriggeredSkill
17
20
 
18
21
 
19
22
  class RefreshConfigTest(unittest.TestCase):
@@ -414,3 +417,84 @@ class _Exploding:
414
417
 
415
418
  if __name__ == "__main__":
416
419
  unittest.main()
420
+
421
+
422
+ def _polled_task(key: str, issue_type: str = "task",
423
+ parent_type: str | None = None) -> Task:
424
+ """One task as a provider hands it over, optionally under a parent.
425
+
426
+ ``codee_work_item_types`` is what every provider fills in from the type
427
+ rows in Settings; here it stands for the default mapping, `story` pointed
428
+ at "Story" and `task` at "Task".
429
+ """
430
+ parent = None
431
+ if parent_type is not None:
432
+ parent = Task(key=f"{key}-parent", summary="The parent",
433
+ status="Active", issue_type=parent_type.casefold(),
434
+ priority="High", work_item_type=parent_type)
435
+ return Task(key=key, summary="Some work", status="Ready",
436
+ issue_type=issue_type, priority="High",
437
+ work_item_type=issue_type.capitalize(), parent=parent,
438
+ codee_work_item_types=frozenset({"story", "task"}))
439
+
440
+
441
+ class ParentWorkItemSkipTest(unittest.TestCase):
442
+ """A poll leaves an item whose parent Codee polls to that parent's run."""
443
+
444
+ def setUp(self) -> None:
445
+ self.provider = Mock()
446
+ self.provider.is_configured.return_value = True
447
+ self.skills = [
448
+ IssueTriggeredSkill(name="Task developer", slug="task-developer",
449
+ path=Path("/repo/.claude/skills/task/SKILL.md"),
450
+ statuses=("Ready",), issue_type="task"),
451
+ IssueTriggeredSkill(name="Story developer", slug="story-developer",
452
+ path=Path("/repo/.claude/skills/story/SKILL.md"),
453
+ statuses=("Ready",), issue_type="story"),
454
+ ]
455
+ for name in ("trigger_cron_skills", "trigger_aws_sqs_skills",
456
+ "trigger_email_skills", "_refresh_config"):
457
+ patcher = patch.object(executor, name)
458
+ self.addCleanup(patcher.stop)
459
+ patcher.start()
460
+ for name, result in (("_pull_latest_code", True),
461
+ ("_load_sessions", {}),
462
+ ("find_issue_triggered_skills", self.skills)):
463
+ patcher = patch.object(executor, name, return_value=result)
464
+ self.addCleanup(patcher.stop)
465
+ patcher.start()
466
+ provider = patch.object(executor, "tasks_provider", self.provider)
467
+ self.addCleanup(provider.stop)
468
+ provider.start()
469
+
470
+ def _submitted(self, *tasks: Task) -> list[str]:
471
+ """The task ids a poll over ``tasks`` actually launched an agent for."""
472
+ self.provider.get_tasks.return_value = list(tasks)
473
+ with patch.object(executor, "_submit_task") as submit:
474
+ executor.run_once()
475
+ return [call.args[0] for call in submit.call_args_list]
476
+
477
+ def test_an_item_under_a_mapped_parent_is_left_to_that_parents_run(self) -> None:
478
+ self.assertEqual(
479
+ self._submitted(_polled_task("NIM-1", parent_type="Story")), [])
480
+
481
+ def test_a_parent_mapped_to_any_work_item_counts(self) -> None:
482
+ # Not only the story: a task under a task is worked by the parent's
483
+ # own run just the same.
484
+ self.assertEqual(
485
+ self._submitted(_polled_task("NIM-2", parent_type="Task")), [])
486
+
487
+ def test_a_story_under_a_mapped_parent_is_skipped_too(self) -> None:
488
+ # The rule is about the parent, not about what the child is.
489
+ self.assertEqual(
490
+ self._submitted(_polled_task("NIM-3", issue_type="story",
491
+ parent_type="Story")), [])
492
+
493
+ def test_an_item_under_an_unmapped_parent_is_worked(self) -> None:
494
+ # An Epic is nothing Codee polls, so nothing else will do this work.
495
+ self.assertEqual(
496
+ self._submitted(_polled_task("NIM-4", parent_type="Epic")),
497
+ ["NIM-4"])
498
+
499
+ def test_an_item_with_no_parent_is_worked(self) -> None:
500
+ self.assertEqual(self._submitted(_polled_task("NIM-5")), ["NIM-5"])
@@ -14,6 +14,7 @@ log = get_logger(__name__)
14
14
  # maintained by hand. It only feeds the admin UI's picker — the editor also takes
15
15
  # a model id typed by hand, so a model missing here is still usable.
16
16
  MODELS = [
17
+ AgentModel("claude-opus-5-5", "Claude Opus 5.5"),
17
18
  AgentModel("claude-opus-5", "Claude Opus 5"),
18
19
  AgentModel("claude-sonnet-5", "Claude Sonnet 5"),
19
20
  AgentModel("claude-fable-5-1", "Claude Fable 5.1"),
@@ -74,6 +74,11 @@ class ClaudeCodeRunTest(unittest.TestCase):
74
74
  # The alias tracks the newest Opus, so no release needs an edit here.
75
75
  self.assertEqual(ClaudeCodeAgent.best_model(), "opus")
76
76
 
77
+ def test_opus_5_5_is_available_in_the_model_catalog(self) -> None:
78
+ models = [(model.id, model.name) for model in self.agent.list_models()]
79
+
80
+ self.assertIn(("claude-opus-5-5", "Claude Opus 5.5"), models)
81
+
77
82
 
78
83
  class ClaudeCodeUsageTest(unittest.TestCase):
79
84
  """Reading a subscription's remaining allowance out of the usage response."""
@@ -29,17 +29,38 @@ class Task:
29
29
  priority: str
30
30
  labels: list[str] = field(default_factory=list)
31
31
  parent: "Task | None" = None
32
+ # What the backend itself calls this item's type ("User Story", "Bug"), as
33
+ # opposed to ``issue_type``, which carries the Codee work item it was
34
+ # polled as. Both are kept because the mapping between them is lossy in
35
+ # both directions: a type Codee was never pointed at passes through
36
+ # unmapped and may collide with a Codee name by accident, and a work item
37
+ # found by a query of the user's own is named by that query rather than by
38
+ # any type at all.
39
+ work_item_type: str = ""
40
+ # Every backend type this installation pointed a Codee work item at,
41
+ # case-folded. Empty for a provider with no such mapping, which is what
42
+ # leaves ``is_parent_codee_work_item`` answering "no" for it.
43
+ codee_work_item_types: frozenset[str] = frozenset()
32
44
 
33
45
  @property
34
- def is_parent_codee_story(self) -> bool:
35
- """Whether this task hangs under a story Codee owns.
36
-
37
- What marks a story as Codee-owned is provider-specific — a label in
38
- JIRA, a work item type in Azure DevOps — so each provider answers this
39
- for its own tasks. The executor only asks the question. A provider that
40
- has no notion of Codee stories inherits "no parent story".
46
+ def is_parent_codee_work_item(self) -> bool:
47
+ """Whether this item hangs under one Codee polls in its own right.
48
+
49
+ A parent of a mapped type gets its own agent run, and that run is what
50
+ decides what its children need — so the child is left alone here
51
+ rather than worked a second time beside it. Whatever state the parent
52
+ rests in, and whether or not the poll itself returned it, the answer is
53
+ the same one its type gives.
54
+
55
+ Only types answer it. A work item the user selects with a query of
56
+ their own (JQL, WIQL) names no type, and nothing in a parent says
57
+ whether that condition would have claimed it — so such a work item
58
+ shields nothing, and its children are polled on their own terms.
41
59
  """
42
- return False
60
+ parent = self.parent
61
+ if parent is None or not parent.work_item_type:
62
+ return False
63
+ return parent.work_item_type.casefold() in self.codee_work_item_types
43
64
 
44
65
 
45
66
  def merge_work_item_tasks(results: list[list["Task"]]) -> list["Task"]:
@@ -9,8 +9,8 @@ from urllib.parse import quote
9
9
 
10
10
  import requests
11
11
  from codee_main_context.context import (
12
- CodeeMainContext, STORY_ISSUE_TYPE, Settings, TASK_ISSUE_TYPE,
13
- TasksProvider, WorkItemMapping, codee_work_items, data_dir, task_filter,
12
+ CodeeMainContext, Settings, TASK_ISSUE_TYPE, TasksProvider,
13
+ WorkItemMapping, codee_work_items, data_dir, task_filter,
14
14
  work_item_mappings)
15
15
  from codee_main_context.logging import get_logger
16
16
  from codee_tasks_abstract.provider import (
@@ -70,7 +70,7 @@ _TIMEOUT = 30
70
70
 
71
71
  def _describe_task(task: Task) -> str:
72
72
  """One work item as a log fragment: what it is and what Codee decided it is."""
73
- raw = getattr(task, "work_item_type", "")
73
+ raw = task.work_item_type
74
74
  mapped = f"{raw}->{task.issue_type}" if raw != task.issue_type else task.issue_type
75
75
  return f"{task.key} [{task.status}/{mapped}]"
76
76
 
@@ -98,46 +98,6 @@ def _describe_error(exc: requests.RequestException) -> str:
98
98
  f": {detail[:300]}" if detail else "")
99
99
 
100
100
 
101
- class AzureDevOpsWorkItem(Task):
102
- """A Task that remembers the raw Azure DevOps work item type.
103
-
104
- ``issue_type`` carries the mapped Codee name, and that mapping is lossy in
105
- both directions: a type Codee was never pointed at passes through unmapped
106
- and may collide with a Codee name by accident. Comparing the raw type
107
- against ``story_work_item_types`` — the backend types this installation
108
- mapped its story to — is what keeps that accident from reading as a real
109
- Codee story.
110
- """
111
-
112
- def __init__(self, work_item_type: str = "",
113
- story_work_item_types: list[str] | None = None,
114
- story_keys: frozenset[str] | set[str] = frozenset(),
115
- **kwargs):
116
- self.work_item_type = work_item_type
117
- self.story_work_item_types = list(story_work_item_types or [])
118
- self.story_keys = story_keys
119
- super().__init__(**kwargs)
120
-
121
- @property
122
- def is_parent_codee_story(self) -> bool:
123
- """In Azure DevOps a Codee-owned story is a type mapped to "story".
124
-
125
- Or, where the story is selected by a WIQL condition instead of a type
126
- list, a parent that condition claims — which the provider establishes
127
- by putting the condition to Azure DevOps with this page's parent ids.
128
- Whatever state the story is resting in, and whether or not the poll
129
- itself returned it, the answer is the same one its type would have
130
- given.
131
- """
132
- parent = self.parent
133
- if not isinstance(parent, AzureDevOpsWorkItem):
134
- return False
135
- if parent.key in self.story_keys:
136
- return True
137
- return parent.work_item_type.casefold() in {
138
- story_type.casefold() for story_type in self.story_work_item_types}
139
-
140
-
141
101
  class AzureDevOpsTasksProvider(AbstractTasksProvider):
142
102
  """Fetches the organization's Codee work items as provider-agnostic Tasks."""
143
103
 
@@ -158,8 +118,12 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
158
118
  self._work_items = work_item_mappings(
159
119
  settings, TasksProvider.AZURE_DEVOPS)
160
120
  self._codee_types = codee_work_items(self._work_items)
161
- self._story_work_item_types = list(self._work_item(
162
- STORY_ISSUE_TYPE).types)
121
+ # The same mapping read as a set of backend types: a work item whose
122
+ # parent is one of them is left to that parent's own run. Only the
123
+ # types are in it — ``codee_work_items`` leaves out the work items
124
+ # selected by a WIQL condition, which is also what the executor's rule
125
+ # is defined in terms of.
126
+ self._codee_parent_types = frozenset(self._codee_types)
163
127
  # An extra WIQL condition the user narrowed the poll with, empty unless
164
128
  # one was configured. Kept as written: it is theirs to get right, and
165
129
  # Azure DevOps explains a rejected query better than a parser here could.
@@ -399,11 +363,9 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
399
363
  # The batch endpoint doesn't preserve the WIQL ordering, so each work
400
364
  # item's tasks are rebuilt in the order its own query asked for.
401
365
  by_id = {item["id"]: item for item in items}
402
- story_keys = self._codee_story_parents(token, parents, raise_errors)
403
366
  results = []
404
367
  for mapping, found in ids_by_work_item:
405
- tasks = [self._to_task(by_id[item_id], parents, mapping.name,
406
- story_keys)
368
+ tasks = [self._to_task(by_id[item_id], parents, mapping.name)
407
369
  for item_id in found if item_id in by_id]
408
370
  log.debug("WIQL for work item %s matched %d work item(s)%s",
409
371
  mapping.name, len(tasks),
@@ -412,48 +374,6 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
412
374
  results.append(tasks)
413
375
  return merge_work_item_tasks(results)
414
376
 
415
- def _codee_story_parents(self, token: str, parents: dict[int, dict],
416
- raise_errors: bool) -> set[str]:
417
- """Which of this page's parents are stories Codee owns.
418
-
419
- A child of one is driven by the story's own agent run, so the executor
420
- has to be told which parents those are. Where the story work item names
421
- types, the parent's own type answers it and nothing is asked. Where it
422
- is selected by a condition, the condition is put to Azure DevOps with
423
- the parents' ids — one query for the whole page, not one per parent,
424
- and no state clause: a story shields its children whatever state it is
425
- resting in, exactly as its type would have.
426
-
427
- When that query fails, every parent is treated as a story. A tick that
428
- does too little is caught by the next one; a child worked beside the
429
- story that is already working it is two agents on one change.
430
- """
431
- story = self._work_item(STORY_ISSUE_TYPE)
432
- if not (story.is_query and parents):
433
- return set()
434
- ids = ", ".join(str(parent_id) for parent_id in sorted(parents))
435
- query = ("SELECT [System.Id] FROM WorkItems "
436
- f"WHERE ({story.query}) AND [System.Id] IN ({ids})")
437
- log.debug("WIQL for the parents of this page: %s", query)
438
- try:
439
- response = requests.post(
440
- f"{self._config.organization_url}/_apis/wit/wiql",
441
- params={"api-version": API_VERSION, "$top": _MAX_TASKS},
442
- json={"query": query},
443
- headers=self._headers(token),
444
- timeout=_TIMEOUT,
445
- )
446
- response.raise_for_status()
447
- except requests.RequestException as exc:
448
- if raise_errors:
449
- raise TasksProviderError(_describe_error(exc)) from exc
450
- log.error("Azure DevOps could not say which parents are stories, "
451
- "so their children wait for the next poll: %s",
452
- _describe_error(exc))
453
- return {str(parent_id) for parent_id in parents}
454
- return {str(item["id"])
455
- for item in response.json().get("workItems") or []}
456
-
457
377
  def _work_item_ids(self, token: str, mapping: WorkItemMapping,
458
378
  statuses: list[str], raise_errors: bool) -> list[int]:
459
379
  """The ids one Codee work item's query returned, in its own order.
@@ -604,26 +524,20 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
604
524
  "Accept": "application/json"}
605
525
 
606
526
  def _to_task(self, item: dict, parents: dict[int, dict],
607
- issue_type: str = "",
608
- story_keys: frozenset[str] | set[str] = frozenset()) -> Task:
527
+ issue_type: str = "") -> Task:
609
528
  """One work item as the executor reads it.
610
529
 
611
530
  ``issue_type`` is the Codee work item whose query returned it, which is
612
531
  the only thing that can name an item a custom condition matched. Left
613
532
  out for a parent, which no query asked for: it falls back to whichever
614
533
  work item claims the type Azure DevOps gave it.
615
-
616
- ``story_keys`` are the parents established as Codee stories, which is
617
- how a child of a story selected by a custom condition is still
618
- recognized as one — its type alone cannot say so.
619
534
  """
620
535
  fields = item.get("fields", {})
621
536
  parent = parents.get(fields.get("System.Parent"))
622
537
  work_item_type = fields.get("System.WorkItemType", "")
623
- return AzureDevOpsWorkItem(
538
+ return Task(
624
539
  work_item_type=work_item_type,
625
- story_work_item_types=self._story_work_item_types,
626
- story_keys=story_keys,
540
+ codee_work_item_types=self._codee_parent_types,
627
541
  key=str(item["id"]),
628
542
  summary=fields.get("System.Title", ""),
629
543
  status=fields.get("System.State", ""),
@@ -422,9 +422,9 @@ class TasksProviderTest(unittest.TestCase):
422
422
 
423
423
  self.assertEqual(tasks[0].issue_type, "task")
424
424
 
425
- def test_a_child_of_any_mapped_story_type_is_flagged(self) -> None:
426
- # Both types are the story this installation drives its children from,
427
- # so a child of either has to be left to the story's own run.
425
+ def test_a_child_of_any_mapped_type_is_flagged(self) -> None:
426
+ # Every type a row names is polled in its own right, so a child of any
427
+ # of them is left to the run the parent gets.
428
428
  settings = _settings(organization_url="https://dev.azure.com/acme",
429
429
  client_id="client-1", client_secret="secret-1")
430
430
  settings.work_item_types = {"azure_devops": {
@@ -434,7 +434,7 @@ class TasksProviderTest(unittest.TestCase):
434
434
 
435
435
  tasks = self._tasks_with_parent_type("Feature")
436
436
 
437
- self.assertTrue(tasks[0].is_parent_codee_story)
437
+ self.assertTrue(tasks[0].is_parent_codee_work_item)
438
438
 
439
439
  def test_a_work_item_with_a_query_is_asked_for_in_its_own_words(self) -> None:
440
440
  # The advanced answer: what a `bug` is cannot be said with type names
@@ -497,91 +497,26 @@ class TasksProviderTest(unittest.TestCase):
497
497
  self.provider = AzureDevOpsTasksProvider(settings, self.context)
498
498
  self._connect()
499
499
 
500
- def test_a_child_of_a_queried_story_is_flagged(self) -> None:
501
- # A story picked out by a condition has no type to recognize it by, so
502
- # the condition is put to Azure DevOps with this page's parent ids.
500
+ def test_a_work_item_selected_by_wiql_shields_nothing(self) -> None:
501
+ # Nothing in a parent says whether the condition would have claimed it,
502
+ # so a work item written as WIQL takes no child out of the poll. Only
503
+ # the types a row names do that.
503
504
  self._queried_story_provider()
504
505
 
505
- tasks = self._tasks_with_parent_type("Feature", story_parents=[30])
506
-
507
- self.assertTrue(tasks[0].is_parent_codee_story)
508
-
509
- def test_a_queried_story_shields_its_children_in_any_state(self) -> None:
510
- # The parent is a story the poll itself never saw — it rests in a state
511
- # no skill triggers on — and its children are still its own to drive,
512
- # exactly as a story named by its type would be.
513
- self._queried_story_provider()
514
-
515
- tasks = self._tasks_with_parent_type(
516
- "Feature", story_ids=[], story_parents=[30])
517
-
518
- self.assertTrue(tasks[0].is_parent_codee_story)
519
-
520
- def test_a_parent_the_story_condition_rejects_is_not_flagged(self) -> None:
521
- self._queried_story_provider()
522
-
523
- tasks = self._tasks_with_parent_type("Feature", story_parents=[])
524
-
525
- self.assertFalse(tasks[0].is_parent_codee_story)
526
-
527
- def test_the_parent_query_asks_only_about_this_page_s_parents(self) -> None:
528
- self._queried_story_provider()
529
- items = _response({"value": [
530
- {"id": 31, "fields": {"System.Title": "A child",
531
- "System.State": "Ready",
532
- "System.WorkItemType": "Task",
533
- "System.Parent": 30}},
534
- ]})
535
- parents = _response({"value": [
536
- {"id": 30, "fields": {"System.Title": "The parent",
537
- "System.State": "Done",
538
- "System.WorkItemType": "Feature"}},
539
- ]})
540
-
541
- with patch("codee_tasks_azure_devops.provider.requests.post",
542
- side_effect=[*_wiql_results([], [31]), items, parents,
543
- *_wiql_results([30])]) as post:
544
- self.provider.get_tasks(["Ready"])
506
+ tasks = self._tasks_with_parent_type("Feature")
545
507
 
546
- query = post.call_args_list[-1].kwargs["json"]["query"]
547
- self.assertIn("([System.Tags] CONTAINS 'codee-story')", query)
548
- self.assertIn("[System.Id] IN (30)", query)
549
- # No state clause: a story shields its children whatever state it is in.
550
- self.assertNotIn("[System.State]", query)
508
+ self.assertFalse(tasks[0].is_parent_codee_work_item)
551
509
 
552
- def test_a_types_story_asks_nothing_extra_about_its_parents(self) -> None:
553
- # The parent's own type is already in hand, so the question is free.
510
+ def test_nothing_extra_is_asked_about_the_parents(self) -> None:
511
+ # A parent's own type comes back with it, so the answer costs no
512
+ # request of its own: one query per work item, the batch read of what
513
+ # they found, and one more for their parents.
554
514
  self._connect()
555
- tasks = self._tasks_with_parent_type("User Story")
556
515
 
557
- self.assertTrue(tasks[0].is_parent_codee_story)
558
-
559
- def test_children_wait_when_the_parent_query_fails(self) -> None:
560
- # A tick that does too little is caught by the next one; a child worked
561
- # beside the story already working it is two agents on one change.
562
- self._queried_story_provider()
563
- items = _response({"value": [
564
- {"id": 31, "fields": {"System.Title": "A child",
565
- "System.State": "Ready",
566
- "System.WorkItemType": "Task",
567
- "System.Parent": 30}},
568
- ]})
569
- parents = _response({"value": [
570
- {"id": 30, "fields": {"System.Title": "The parent",
571
- "System.State": "Done",
572
- "System.WorkItemType": "Feature"}},
573
- ]})
574
- rejected = Mock(status_code=400, text="")
575
- rejected.json.return_value = {"message": "TF51005: no such field."}
576
- rejected.raise_for_status.side_effect = requests.HTTPError(
577
- "400 Client Error", response=rejected)
578
-
579
- with patch("codee_tasks_azure_devops.provider.requests.post",
580
- side_effect=[*_wiql_results([], [31]), items, parents,
581
- rejected]):
582
- tasks = self.provider.get_tasks(["Ready"])
516
+ tasks = self._tasks_with_parent_type("User Story")
583
517
 
584
- self.assertTrue(tasks[0].is_parent_codee_story)
518
+ self.assertTrue(tasks[0].is_parent_codee_work_item)
519
+ self.assertEqual(self.post.call_count, 4)
585
520
 
586
521
  def test_work_items_are_interleaved_rather_than_concatenated(self) -> None:
587
522
  # Each query is priority-ordered on its own, and nothing orders them
@@ -744,20 +679,29 @@ class TasksProviderTest(unittest.TestCase):
744
679
 
745
680
  self.assertEqual(tasks[0].issue_type, "story")
746
681
 
747
- def test_a_child_of_a_codee_story_is_flagged(self) -> None:
682
+ def test_a_child_of_a_mapped_parent_type_is_flagged(self) -> None:
748
683
  self._connect()
749
684
  tasks = self._tasks_with_parent_type("User Story")
750
685
 
751
- self.assertTrue(tasks[0].is_parent_codee_story)
686
+ self.assertTrue(tasks[0].is_parent_codee_work_item)
687
+
688
+ def test_a_child_of_a_mapped_task_type_is_flagged_too(self) -> None:
689
+ # The rule is not about stories: a Task under a Task is worked inside
690
+ # the parent's own run just the same.
691
+ self._connect()
692
+
693
+ tasks = self._tasks_with_parent_type("Task")
694
+
695
+ self.assertTrue(tasks[0].is_parent_codee_work_item)
752
696
 
753
- def test_a_child_of_an_unmapped_story_type_is_not_flagged(self) -> None:
697
+ def test_a_child_of_an_unmapped_parent_type_is_not_flagged(self) -> None:
754
698
  self._connect()
755
- # "Story" is not the type this installation mapped its story to, and
699
+ # "Story" is not a type this installation pointed a work item at, and
756
700
  # the accidental collision with the Codee name must not read as one.
757
701
  tasks = self._tasks_with_parent_type("Story")
758
702
 
759
703
  self.assertEqual(tasks[0].parent.issue_type, "Story")
760
- self.assertFalse(tasks[0].is_parent_codee_story)
704
+ self.assertFalse(tasks[0].is_parent_codee_work_item)
761
705
 
762
706
  def test_a_task_without_a_parent_is_not_flagged(self) -> None:
763
707
  self._connect()
@@ -771,16 +715,14 @@ class TasksProviderTest(unittest.TestCase):
771
715
  side_effect=[*_wiql_results([], [31]), items]):
772
716
  tasks = self.provider.get_tasks(["Ready"])
773
717
 
774
- self.assertFalse(tasks[0].is_parent_codee_story)
718
+ self.assertFalse(tasks[0].is_parent_codee_work_item)
775
719
 
776
720
  def _tasks_with_parent_type(self, parent_type: str,
777
- story_ids: list[int] | None = None,
778
- story_parents: list[int] | None = None) -> list:
721
+ story_ids: list[int] | None = None) -> list:
779
722
  """One task under one parent, as a poll returns it.
780
723
 
781
- ``story_parents`` is what the extra query answers when the story work
782
- item is selected by a condition: which of this page's parents it
783
- claims. Left out where the story names types, since nothing is asked.
724
+ The mock it ran against is left on ``self.post``, for the tests that
725
+ care what answering the parent cost.
784
726
  """
785
727
  items = _response({"value": [
786
728
  {"id": 31, "fields": {"System.Title": "A child",
@@ -794,11 +736,10 @@ class TasksProviderTest(unittest.TestCase):
794
736
  "System.WorkItemType": parent_type}},
795
737
  ]})
796
738
 
797
- responses = [*_wiql_results(story_ids or [], [31]), items, parents]
798
- if story_parents is not None:
799
- responses.extend(_wiql_results(story_parents))
800
739
  with patch("codee_tasks_azure_devops.provider.requests.post",
801
- side_effect=responses):
740
+ side_effect=[*_wiql_results(story_ids or [], [31]),
741
+ items, parents]) as post:
742
+ self.post = post
802
743
  return self.provider.get_tasks(["Ready"])
803
744
 
804
745
  def test_empty_result_skips_the_batch_call(self) -> None:
@@ -1,4 +1,3 @@
1
- from typing import Callable
2
1
  from urllib.parse import quote
3
2
 
4
3
  import requests
@@ -14,10 +13,6 @@ from codee_tasks_abstract.provider import (
14
13
 
15
14
  log = get_logger(__name__)
16
15
 
17
- # The label that marks a JIRA story as Codee-owned. Children of such a story
18
- # are driven by the story's own agent run, so the executor leaves them alone.
19
- CODEE_STORY_LABEL = "CodeeStory"
20
-
21
16
  # Atlassian's own MCP server, run straight from PyPI through `uvx` so the only
22
17
  # thing that has to exist on the machine is uv — no install step to keep in sync
23
18
  # with the credentials below.
@@ -57,43 +52,6 @@ def _describe_error(exc: requests.RequestException) -> str:
57
52
  f": {detail[:300]}" if detail else "")
58
53
 
59
54
 
60
- class JiraTask(Task):
61
- """A JIRA task whose labels are fetched the first time they're read.
62
-
63
- A parent reference in the search response carries no labels, so resolving
64
- them eagerly would cost an extra request per tick even when the caller
65
- never inspects them. Deferring the fetch keeps the common path request-free;
66
- the result is cached so repeated reads don't re-fetch.
67
- """
68
-
69
- def __init__(self, labels_loader: Callable[[], list[str]] | None = None, **kwargs):
70
- self._labels_loader = labels_loader
71
- self._resolved_labels: list[str] | None = None
72
- super().__init__(**kwargs)
73
-
74
- @property
75
- def labels(self) -> list[str]:
76
- if self._resolved_labels is None:
77
- if self._raw_labels is not None:
78
- self._resolved_labels = self._raw_labels
79
- elif self._labels_loader is not None:
80
- self._resolved_labels = self._labels_loader()
81
- else:
82
- self._resolved_labels = []
83
- return self._resolved_labels
84
-
85
- @labels.setter
86
- def labels(self, value: list[str] | None) -> None:
87
- # Set by Task.__init__; None means "not present in the response".
88
- self._raw_labels = value
89
-
90
- @property
91
- def is_parent_codee_story(self) -> bool:
92
- """In JIRA a Codee-owned story is marked with the CodeeStory label."""
93
- return (self.parent is not None
94
- and CODEE_STORY_LABEL in self.parent.labels)
95
-
96
-
97
55
  class JiraTasksProvider(AbstractTasksProvider):
98
56
  """Fetches AI-owned issues from JIRA and maps them to provider-agnostic Tasks."""
99
57
 
@@ -116,6 +74,12 @@ class JiraTasksProvider(AbstractTasksProvider):
116
74
  # query asked for — a parent is fetched without a type filter.
117
75
  self._work_items = work_item_mappings(settings, TasksProvider.JIRA)
118
76
  self._codee_types = codee_work_items(self._work_items)
77
+ # The same mapping read as a set of backend types: an issue whose
78
+ # parent is one of them is left to that parent's own run. Only the
79
+ # types are in it — ``codee_work_items`` leaves out the work items
80
+ # selected by a JQL condition, which is also what the executor's rule
81
+ # is defined in terms of.
82
+ self._codee_parent_types = frozenset(self._codee_types)
119
83
  # An extra JQL condition the user narrowed the poll with, empty unless
120
84
  # one was configured. Kept as written: it is theirs to get right, and
121
85
  # JIRA says what is wrong with it far better than a parser here could.
@@ -397,12 +361,14 @@ class JiraTasksProvider(AbstractTasksProvider):
397
361
  """
398
362
  fields = issue.get("fields", {})
399
363
  parent_issue = fields.get("parent")
400
- return JiraTask(
364
+ jira_type = fields.get("issuetype", {}).get("name", "")
365
+ return Task(
401
366
  key=issue["key"],
402
367
  summary=fields.get("summary", ""),
403
368
  status=fields.get("status", {}).get("name", ""),
404
- issue_type=issue_type or self._codee_issue_type(
405
- fields.get("issuetype", {}).get("name", "")),
369
+ work_item_type=jira_type,
370
+ codee_work_item_types=self._codee_parent_types,
371
+ issue_type=issue_type or self._codee_issue_type(jira_type),
406
372
  priority=(fields.get("priority") or {}).get("name", "Unknown"),
407
373
  labels=fields.get("labels") or [],
408
374
  parent=self._to_parent_task(
@@ -410,33 +376,21 @@ class JiraTasksProvider(AbstractTasksProvider):
410
376
  )
411
377
 
412
378
  def _to_parent_task(self, parent_issue: dict) -> Task:
413
- # Labels aren't included for a parent, so defer the fetch until read.
414
- key = parent_issue["key"]
379
+ """The issue above a Codee one, out of the reference JIRA inlined.
380
+
381
+ Costs no request: a search response carries the parent's own fields,
382
+ including the type that decides whether this issue is left to the
383
+ parent's run. Its own parent is not resolved — the executor looks one
384
+ level up, and the reference carries no grandparent anyway.
385
+ """
415
386
  fields = parent_issue.get("fields", {})
416
- return JiraTask(
417
- key=key,
387
+ jira_type = fields.get("issuetype", {}).get("name", "")
388
+ return Task(
389
+ key=parent_issue["key"],
418
390
  summary=fields.get("summary", ""),
419
391
  status=fields.get("status", {}).get("name", ""),
420
- issue_type=self._codee_issue_type(
421
- fields.get("issuetype", {}).get("name", "")),
392
+ work_item_type=jira_type,
393
+ issue_type=self._codee_issue_type(jira_type),
422
394
  priority=(fields.get("priority") or {}).get("name", "Unknown"),
423
- labels=fields.get("labels"),
424
- labels_loader=lambda: self._fetch_issue_labels(key),
395
+ labels=fields.get("labels") or [],
425
396
  )
426
-
427
- def _fetch_issue_labels(self, issue_key: str) -> list[str]:
428
- """Fetch labels for a single JIRA issue."""
429
- url = f"{self._base_url}/rest/api/3/issue/{issue_key}"
430
- try:
431
- resp = requests.get(
432
- url,
433
- params={"fields": "labels"},
434
- auth=(self._user_email, self._api_token),
435
- headers={"Accept": "application/json"},
436
- timeout=30,
437
- )
438
- resp.raise_for_status()
439
- return resp.json().get("fields", {}).get("labels", [])
440
- except requests.RequestException as exc:
441
- log.error("Failed to fetch labels for %s: %s", issue_key, exc)
442
- return []
@@ -26,12 +26,13 @@ def _configure(provider: JiraTasksProvider,
26
26
  else work_item_mappings(Settings()))
27
27
  provider._work_items = work_items
28
28
  provider._codee_types = codee_work_items(work_items)
29
+ provider._codee_parent_types = frozenset(provider._codee_types)
29
30
  provider._task_filter = ""
30
31
  return provider
31
32
 
32
33
 
33
- def _issue(key: str = "CORE-1",
34
- parent_labels: list[str] | None = None) -> dict:
34
+ def _issue(key: str = "CORE-1", parent_type: str | None = None) -> dict:
35
+ """One search result, hanging under a parent of ``parent_type`` if given."""
35
36
  issue = {"key": key, "fields": {
36
37
  "summary": "A task",
37
38
  "status": {"name": "Ready"},
@@ -39,48 +40,72 @@ def _issue(key: str = "CORE-1",
39
40
  "priority": {"name": "High"},
40
41
  "labels": [],
41
42
  }}
42
- if parent_labels is not None:
43
+ if parent_type is not None:
43
44
  issue["fields"]["parent"] = {
44
45
  "key": "CORE-9",
45
46
  "fields": {"summary": "A story",
46
47
  "status": {"name": "In Progress"},
47
- "issuetype": {"name": "Story"},
48
- "labels": parent_labels},
48
+ "issuetype": {"name": parent_type},
49
+ "labels": ["backend"]},
49
50
  }
50
51
  return issue
51
52
 
52
53
 
53
- class JiraParentStoryTest(unittest.TestCase):
54
+ class JiraParentWorkItemTest(unittest.TestCase):
55
+ """Which issues are left to the run of the issue above them."""
56
+
54
57
  def setUp(self) -> None:
55
58
  self.provider = _configure(
56
59
  JiraTasksProvider.__new__(JiraTasksProvider))
57
60
 
58
- def test_parent_with_the_label_is_a_codee_story(self) -> None:
59
- task = self.provider._to_task(_issue(parent_labels=["CodeeStory", "backend"]))
61
+ def test_a_parent_of_a_mapped_type_is_a_codee_work_item(self) -> None:
62
+ task = self.provider._to_task(_issue(parent_type="Story"))
63
+
64
+ self.assertTrue(task.is_parent_codee_work_item)
65
+
66
+ def test_a_parent_mapped_to_the_same_work_item_counts_too(self) -> None:
67
+ # Not only the story: a task under a task gets its work from the
68
+ # parent's own run just the same, whichever row selected the parent.
69
+ task = self.provider._to_task(_issue(parent_type="Task"))
70
+
71
+ self.assertTrue(task.is_parent_codee_work_item)
72
+
73
+ def test_a_parent_no_row_selects_is_not_one(self) -> None:
74
+ # An Epic above a Codee story is nothing Codee polls, so the issue
75
+ # under it is worked on its own.
76
+ task = self.provider._to_task(_issue(parent_type="Epic"))
60
77
 
61
- self.assertTrue(task.is_parent_codee_story)
78
+ self.assertFalse(task.is_parent_codee_work_item)
62
79
 
63
- def test_parent_without_the_label_is_not(self) -> None:
64
- task = self.provider._to_task(_issue(parent_labels=["backend"]))
80
+ def test_the_type_is_matched_however_it_is_cased(self) -> None:
81
+ task = self.provider._to_task(_issue(parent_type="story"))
65
82
 
66
- self.assertFalse(task.is_parent_codee_story)
83
+ self.assertTrue(task.is_parent_codee_work_item)
67
84
 
68
- def test_a_task_with_no_parent_is_not(self) -> None:
85
+ def test_an_issue_with_no_parent_is_not(self) -> None:
69
86
  task = self.provider._to_task(_issue())
70
87
 
71
- self.assertFalse(task.is_parent_codee_story)
88
+ self.assertFalse(task.is_parent_codee_work_item)
72
89
 
73
- def test_labels_missing_from_the_parent_are_fetched_once(self) -> None:
74
- parent = _issue(parent_labels=[])
75
- del parent["fields"]["parent"]["fields"]["labels"]
76
- task = self.provider._to_task(parent)
90
+ def test_a_work_item_selected_by_jql_shields_nothing(self) -> None:
91
+ # Nothing in a parent says whether the condition would have claimed it,
92
+ # so a work item written as JQL takes no children out of the poll.
93
+ provider = _configure(JiraTasksProvider.__new__(JiraTasksProvider),
94
+ {"story": "labels = CodeeStory",
95
+ "task": ["Task"]})
96
+
97
+ task = provider._to_task(_issue(parent_type="Story"))
77
98
 
78
- with patch.object(self.provider, "_fetch_issue_labels",
79
- return_value=["CodeeStory"]) as fetch:
80
- self.assertTrue(task.is_parent_codee_story)
81
- self.assertTrue(task.is_parent_codee_story)
99
+ self.assertFalse(task.is_parent_codee_work_item)
82
100
 
83
- fetch.assert_called_once_with("CORE-9")
101
+ def test_the_parent_comes_out_of_the_search_response(self) -> None:
102
+ # Its type is already inlined there, so the answer costs no request.
103
+ with patch("codee_tasks_jira.provider.requests.get") as get:
104
+ task = self.provider._to_task(_issue(parent_type="Story"))
105
+
106
+ self.assertTrue(task.is_parent_codee_work_item)
107
+ get.assert_not_called()
108
+ self.assertEqual(task.parent.key, "CORE-9")
84
109
 
85
110
 
86
111
  def _error(status: int, payload: dict | None = None) -> requests.HTTPError:
@@ -425,7 +450,7 @@ class JiraIssueTypeMappingTest(unittest.TestCase):
425
450
  provider = _configure(JiraTasksProvider.__new__(JiraTasksProvider),
426
451
  {"story": ["Epic"], "task": ["Task"]})
427
452
 
428
- task = provider._to_task(_issue(parent_labels=["backend"]))
453
+ task = provider._to_task(_issue(parent_type="Story"))
429
454
 
430
455
  self.assertEqual(task.parent.issue_type, "Story")
431
456
 
File without changes
File without changes