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.
- {codee_agent-0.6.3 → codee_agent-0.6.4}/PKG-INFO +1 -1
- {codee_agent-0.6.3 → codee_agent-0.6.4}/pyproject.toml +1 -1
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/executor.py +12 -8
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_executor.py +84 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/provider.py +1 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/test.py +5 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_abstract/provider.py +29 -8
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_azure_devops/provider.py +13 -99
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_azure_devops/test.py +38 -97
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_jira/provider.py +24 -70
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_jira/test.py +49 -24
- {codee_agent-0.6.3 → codee_agent-0.6.4}/LICENSE +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/README.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/.gitignore +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/admin.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/admin_api.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/admin_cli.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/admin_service.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/agent_cli.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/coding_agents.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/init_cli.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/claude_key_rotation.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/cron_describe.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/mcp_config.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/runs_db.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_claude_key_rotation.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_mcp_config.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_runs_db.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_trigger_cron_skills.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/test_trigger_issue_skills.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/trigger_cron_skills.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/trigger_email_skills.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/lib/trigger_issue_skills.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/mail_server.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/setup_wizard.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/start_cli.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/tasks_providers.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/AGENTS.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/CLAUDE.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_admin_api.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_admin_cli.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_admin_service.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_agent_cli.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_coding_agents.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_init_cli.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_memory_index.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_setup_wizard.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/test_start_cli.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/workflow_graph.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_admin/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_admin/codee_admin.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_abstract/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_abstract/provider.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/account.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/credentials.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/oauth.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_claude_code/usage.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_codex/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_codex/provider.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_codex/test.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_github_copilot/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_github_copilot/provider.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_agent_github_copilot/test.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_database/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_database/claude_code_accounts.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_database/database.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_database/oauth_tokens.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/context.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/logging.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/test_context.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_main_context/test_logging.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_abstract/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_azure_devops/__init__.py +0 -0
- {codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee_tasks_azure_devops/oauth.py +0 -0
- {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
|
+
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
|
|
@@ -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,
|
|
15
|
-
|
|
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
|
-
#
|
|
428
|
-
#
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|
35
|
-
"""Whether this
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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,
|
|
13
|
-
|
|
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 =
|
|
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
|
-
|
|
162
|
-
|
|
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
|
|
538
|
+
return Task(
|
|
624
539
|
work_item_type=work_item_type,
|
|
625
|
-
|
|
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
|
|
426
|
-
#
|
|
427
|
-
#
|
|
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].
|
|
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
|
|
501
|
-
#
|
|
502
|
-
#
|
|
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"
|
|
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
|
-
|
|
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
|
|
553
|
-
#
|
|
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.
|
|
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].
|
|
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
|
|
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].
|
|
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
|
|
697
|
+
def test_a_child_of_an_unmapped_parent_type_is_not_flagged(self) -> None:
|
|
754
698
|
self._connect()
|
|
755
|
-
# "Story" is not
|
|
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].
|
|
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].
|
|
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
|
-
|
|
782
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
|
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":
|
|
48
|
-
"labels":
|
|
48
|
+
"issuetype": {"name": parent_type},
|
|
49
|
+
"labels": ["backend"]},
|
|
49
50
|
}
|
|
50
51
|
return issue
|
|
51
52
|
|
|
52
53
|
|
|
53
|
-
class
|
|
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
|
|
59
|
-
task = self.provider._to_task(_issue(
|
|
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.
|
|
78
|
+
self.assertFalse(task.is_parent_codee_work_item)
|
|
62
79
|
|
|
63
|
-
def
|
|
64
|
-
task = self.provider._to_task(_issue(
|
|
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.
|
|
83
|
+
self.assertTrue(task.is_parent_codee_work_item)
|
|
67
84
|
|
|
68
|
-
def
|
|
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.
|
|
88
|
+
self.assertFalse(task.is_parent_codee_work_item)
|
|
72
89
|
|
|
73
|
-
def
|
|
74
|
-
parent
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-code-reviewer/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/story-security-reviewer/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-code-reviewer/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codee_agent-0.6.3 → codee_agent-0.6.4}/src/codee/templates/skills/task-security-reviewer/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|