codee-agent 0.5.1__tar.gz → 0.5.3__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 (81) hide show
  1. {codee_agent-0.5.1 → codee_agent-0.5.3}/PKG-INFO +1 -1
  2. {codee_agent-0.5.1 → codee_agent-0.5.3}/pyproject.toml +1 -1
  3. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/admin.py +2 -1
  4. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/admin_service.py +14 -5
  5. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/test_admin_service.py +72 -17
  6. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_tasks_abstract/provider.py +1 -1
  7. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_tasks_azure_devops/provider.py +49 -28
  8. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_tasks_azure_devops/test.py +36 -3
  9. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_tasks_jira/provider.py +1 -1
  10. {codee_agent-0.5.1 → codee_agent-0.5.3}/LICENSE +0 -0
  11. {codee_agent-0.5.1 → codee_agent-0.5.3}/README.md +0 -0
  12. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/.gitignore +0 -0
  13. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/__init__.py +0 -0
  14. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/admin_api.py +0 -0
  15. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/admin_cli.py +0 -0
  16. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/agent_cli.py +0 -0
  17. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/coding_agents.py +0 -0
  18. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/executor.py +0 -0
  19. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/init_cli.py +0 -0
  20. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/__init__.py +0 -0
  21. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/cron_describe.py +0 -0
  22. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/mcp_config.py +0 -0
  23. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/runs_db.py +0 -0
  24. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/test_mcp_config.py +0 -0
  25. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/test_runs_db.py +0 -0
  26. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  27. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  28. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  29. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/trigger_cron_skills.py +0 -0
  30. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/trigger_email_skills.py +0 -0
  31. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/lib/trigger_issue_skills.py +0 -0
  32. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/mail_server.py +0 -0
  33. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/setup_wizard.py +0 -0
  34. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/start_cli.py +0 -0
  35. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/tasks_providers.py +0 -0
  36. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/AGENTS.md +0 -0
  37. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/CLAUDE.md +0 -0
  38. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  39. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  40. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  41. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  42. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  43. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  44. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  45. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  46. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  47. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  48. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  49. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  50. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/test_admin_api.py +0 -0
  51. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/test_admin_cli.py +0 -0
  52. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/test_agent_cli.py +0 -0
  53. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/test_executor.py +0 -0
  54. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/test_init_cli.py +0 -0
  55. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/test_memory_index.py +0 -0
  56. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/test_setup_wizard.py +0 -0
  57. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/test_start_cli.py +0 -0
  58. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee/workflow_graph.py +0 -0
  59. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_admin/__init__.py +0 -0
  60. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_admin/codee_admin.py +0 -0
  61. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_agent_abstract/__init__.py +0 -0
  62. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_agent_abstract/provider.py +0 -0
  63. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_agent_claude_code/__init__.py +0 -0
  64. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_agent_claude_code/provider.py +0 -0
  65. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_agent_claude_code/test.py +0 -0
  66. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_agent_github_copilot/__init__.py +0 -0
  67. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_agent_github_copilot/provider.py +0 -0
  68. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_agent_github_copilot/test.py +0 -0
  69. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_database/__init__.py +0 -0
  70. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_database/database.py +0 -0
  71. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_database/oauth_tokens.py +0 -0
  72. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_main_context/__init__.py +0 -0
  73. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_main_context/context.py +0 -0
  74. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_main_context/logging.py +0 -0
  75. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_main_context/test_context.py +0 -0
  76. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_main_context/test_logging.py +0 -0
  77. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_tasks_abstract/__init__.py +0 -0
  78. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_tasks_azure_devops/__init__.py +0 -0
  79. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_tasks_azure_devops/oauth.py +0 -0
  80. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_tasks_jira/__init__.py +0 -0
  81. {codee_agent-0.5.1 → codee_agent-0.5.3}/src/codee_tasks_jira/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.5.1
3
+ Version: 0.5.3
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,automation
6
6
  Author: Denis Kibalko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.5.1"
3
+ version = "0.5.3"
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"
@@ -2238,7 +2238,8 @@ def tasks_check_row(check: CheckResult) -> rx.Component:
2238
2238
  color=rx.cond(check.status == "waiting", MUTED, TEXT)),
2239
2239
  rx.cond(
2240
2240
  check.message != "",
2241
- rx.text(check.message, color=MUTED, font_size="0.8rem")),
2241
+ rx.text(check.message, color=MUTED, font_size="0.8rem",
2242
+ white_space="pre-wrap", overflow_wrap="anywhere")),
2242
2243
  spacing="1", align="start", width="100%"),
2243
2244
  spacing="2", align="start", width="100%")
2244
2245
 
@@ -159,7 +159,9 @@ def _mcp_check_prompt(server_name: str, steps: list[str]) -> str:
159
159
  '"status": "<the status you left it in>"}\n'
160
160
  "If any step did not complete:\n"
161
161
  '{"ok": false, "error": "<one sentence naming the step that failed '
162
- 'and why>"}'
162
+ 'and why>", "request": "<the complete MCP tool request exactly as '
163
+ 'sent, or null if none was sent>", "response": "<the complete MCP '
164
+ 'response or error exactly as received, or null if none was received>"}'
163
165
  )
164
166
 
165
167
 
@@ -177,11 +179,18 @@ def _mcp_check_result(server_name: str, response: str) -> dict[str, Any]:
177
179
  raise ValueError
178
180
  except ValueError:
179
181
  return _check(MCP_CHECK, False, "The agent did not report a result: "
180
- f"{response.strip()[:300] or 'it said nothing'}")
182
+ f"{response.strip() or 'it said nothing'}")
181
183
  if not payload.get("ok"):
182
- return _check(MCP_CHECK, False,
183
- str(payload.get("error")
184
- or "The agent reported a failure."))
184
+ message = str(payload.get("error")
185
+ or "The agent reported a failure.")
186
+ if "request" in payload or "response" in payload:
187
+ request = json.dumps(payload.get("request"), indent=2,
188
+ ensure_ascii=False)
189
+ response = json.dumps(payload.get("response"), indent=2,
190
+ ensure_ascii=False)
191
+ message += (f"\n\nMCP request:\n{request}"
192
+ f"\n\nMCP response:\n{response}")
193
+ return _check(MCP_CHECK, False, message)
185
194
  task = str(payload.get("task") or "a task").strip()
186
195
  status = str(payload.get("status") or "a closed status").strip()
187
196
  return _check(MCP_CHECK, True,
@@ -911,7 +911,8 @@ class AdminServiceSkillExtraFrontmatterTest(unittest.TestCase):
911
911
 
912
912
  def test_save_writes_the_extra_fields_into_the_frontmatter(self) -> None:
913
913
  with tempfile.TemporaryDirectory() as temporary_directory:
914
- service = self._service(Path(temporary_directory), "name: nightly\n")
914
+ service = self._service(
915
+ Path(temporary_directory), "name: nightly\n")
915
916
 
916
917
  write, _ = self._save(
917
918
  service, "allowed-tools: Bash\ncompatibility: Claude Code\n")
@@ -942,9 +943,11 @@ class AdminServiceSkillExtraFrontmatterTest(unittest.TestCase):
942
943
 
943
944
  def test_invalid_yaml_is_reported_and_nothing_is_written(self) -> None:
944
945
  with tempfile.TemporaryDirectory() as temporary_directory:
945
- service = self._service(Path(temporary_directory), "name: nightly\n")
946
+ service = self._service(
947
+ Path(temporary_directory), "name: nightly\n")
946
948
 
947
- write, (saved, _, message, slug) = self._save(service, "allowed-tools")
949
+ write, (saved, _, message, slug) = self._save(
950
+ service, "allowed-tools")
948
951
 
949
952
  write.assert_not_called()
950
953
  self.assertFalse(saved)
@@ -953,7 +956,8 @@ class AdminServiceSkillExtraFrontmatterTest(unittest.TestCase):
953
956
 
954
957
  def test_a_field_that_has_its_own_control_is_rejected(self) -> None:
955
958
  with tempfile.TemporaryDirectory() as temporary_directory:
956
- service = self._service(Path(temporary_directory), "name: nightly\n")
959
+ service = self._service(
960
+ Path(temporary_directory), "name: nightly\n")
957
961
 
958
962
  write, (saved, _, message, _) = self._save(
959
963
  service, "model: claude-opus-5\n")
@@ -973,7 +977,8 @@ class AdminServiceSkillExtraFrontmatterTest(unittest.TestCase):
973
977
 
974
978
  def test_load_returns_an_empty_string_when_there_are_none(self) -> None:
975
979
  with tempfile.TemporaryDirectory() as temporary_directory:
976
- service = self._service(Path(temporary_directory), "name: nightly\n")
980
+ service = self._service(
981
+ Path(temporary_directory), "name: nightly\n")
977
982
 
978
983
  self.assertEqual(service.load_skill("nightly")["extra"], "")
979
984
 
@@ -991,12 +996,14 @@ class AdminServiceAgentModelsTest(unittest.TestCase):
991
996
  service = self._service(CodingAgent.CLAUDE_CODE)
992
997
 
993
998
  with patch.object(ClaudeCodeAgent, "list_models",
994
- return_value=[AgentModel("claude-opus-5", "Claude Opus 5")]
999
+ return_value=[AgentModel(
1000
+ "claude-opus-5", "Claude Opus 5")]
995
1001
  ) as list_models:
996
1002
  first = service.list_agent_models()
997
1003
  second = service.list_agent_models()
998
1004
 
999
- self.assertEqual(first, [{"id": "claude-opus-5", "name": "Claude Opus 5"}])
1005
+ self.assertEqual(
1006
+ first, [{"id": "claude-opus-5", "name": "Claude Opus 5"}])
1000
1007
  self.assertEqual(second, first)
1001
1008
  list_models.assert_called_once()
1002
1009
 
@@ -1092,10 +1099,12 @@ class AdminServiceRepositoriesTest(unittest.TestCase):
1092
1099
  return result.stdout.strip()
1093
1100
 
1094
1101
  def test_name_is_taken_from_every_url_form(self) -> None:
1095
- self.assertEqual(repository_name("git@github.com:org/codee.git"), "codee")
1102
+ self.assertEqual(repository_name(
1103
+ "git@github.com:org/codee.git"), "codee")
1096
1104
  self.assertEqual(repository_name("ssh://git@github.com/org/codee.git"),
1097
1105
  "codee")
1098
- self.assertEqual(repository_name("https://github.com/org/codee/"), "codee")
1106
+ self.assertEqual(repository_name(
1107
+ "https://github.com/org/codee/"), "codee")
1099
1108
  self.assertEqual(repository_name(" "), "")
1100
1109
 
1101
1110
  def test_add_builds_the_bare_and_worktree_layout(self) -> None:
@@ -1197,7 +1206,8 @@ class AzureDevOpsOAuthTest(unittest.TestCase):
1197
1206
  tasks_provider=TasksProvider.AZURE_DEVOPS,
1198
1207
  credentials={"azure_devops": credentials
1199
1208
  if credentials is not None else self.AZURE_CREDENTIALS})
1200
- service.context = CodeeMainContext(data_dir=data_dir, settings=settings)
1209
+ service.context = CodeeMainContext(
1210
+ data_dir=data_dir, settings=settings)
1201
1211
  save_settings(data_dir, settings)
1202
1212
  return service
1203
1213
 
@@ -1241,14 +1251,16 @@ class AzureDevOpsOAuthTest(unittest.TestCase):
1241
1251
 
1242
1252
  with patch.object(azure_oauth, "exchange_code", return_value=tokens) as exchange, \
1243
1253
  patch.object(azure_oauth, "fetch_account", return_value="dev@acme.com"):
1244
- connected, message = service.complete_azure_authorization("code-1", state)
1254
+ connected, message = service.complete_azure_authorization(
1255
+ "code-1", state)
1245
1256
 
1246
1257
  self.assertTrue(started)
1247
1258
  self.assertTrue(connected)
1248
1259
  self.assertIn("dev@acme.com", message)
1249
1260
  # The exchange must reuse the redirect URI the authorization was issued
1250
1261
  # with — Entra rejects the code otherwise.
1251
- self.assertEqual(exchange.call_args.args[1], service.azure_redirect_uri())
1262
+ self.assertEqual(
1263
+ exchange.call_args.args[1], service.azure_redirect_uri())
1252
1264
  self.assertTrue(service.azure_connection()["connected"])
1253
1265
 
1254
1266
  def test_callback_with_a_forged_state_stores_nothing(self) -> None:
@@ -1274,7 +1286,8 @@ class AzureDevOpsOAuthTest(unittest.TestCase):
1274
1286
  with patch.object(azure_oauth, "exchange_code",
1275
1287
  side_effect=azure_oauth.AzureDevOpsAuthError(
1276
1288
  "AADSTS7000215: Invalid client secret.")):
1277
- connected, message = service.complete_azure_authorization("code-1", state)
1289
+ connected, message = service.complete_azure_authorization(
1290
+ "code-1", state)
1278
1291
 
1279
1292
  self.assertFalse(connected)
1280
1293
  self.assertIn("Invalid client secret", message)
@@ -1312,7 +1325,8 @@ class VerifyTasksConnectionTest(unittest.TestCase):
1312
1325
  service.skills_dir = _write_issue_skill(root)
1313
1326
  service.data_dir = root / ".codee"
1314
1327
  service.data_dir.mkdir()
1315
- settings = Settings(credentials={"jira": {"base_url": "https://stale.test"}})
1328
+ settings = Settings(
1329
+ credentials={"jira": {"base_url": "https://stale.test"}})
1316
1330
  service.context = CodeeMainContext(
1317
1331
  data_dir=service.data_dir, settings=settings)
1318
1332
  save_settings(service.data_dir, settings)
@@ -1463,6 +1477,10 @@ class VerifyTasksMcpCheckTest(unittest.TestCase):
1463
1477
  self.prompt)
1464
1478
  self.assertIn("2. Move that issue to a Done or Cancelled status",
1465
1479
  self.prompt)
1480
+ self.assertIn("complete MCP tool request exactly as sent",
1481
+ self.prompt)
1482
+ self.assertIn("complete MCP response or error exactly as received",
1483
+ self.prompt)
1466
1484
 
1467
1485
  def test_a_fenced_reply_is_still_read(self) -> None:
1468
1486
  with tempfile.TemporaryDirectory() as temporary_directory:
@@ -1484,6 +1502,40 @@ class VerifyTasksMcpCheckTest(unittest.TestCase):
1484
1502
  self.assertEqual(check["message"],
1485
1503
  "No create-issue tool was offered.")
1486
1504
 
1505
+ def test_a_reported_failure_includes_the_full_mcp_exchange(self) -> None:
1506
+ with tempfile.TemporaryDirectory() as temporary_directory:
1507
+ service = self._service(Path(temporary_directory))
1508
+ request = {
1509
+ "tool": "ado-wit_work_item_write",
1510
+ "arguments": {"fields": [{"name": "System.Title",
1511
+ "value": "Codee check"}]},
1512
+ }
1513
+ response = {
1514
+ "error": {"code": "invalid_type",
1515
+ "message": "Input validation failed"}}
1516
+
1517
+ check = self._run(service, json.dumps({
1518
+ "ok": False,
1519
+ "error": "Creating the work item failed.",
1520
+ "request": request,
1521
+ "response": response,
1522
+ }))
1523
+
1524
+ self.assertFalse(check["ok"])
1525
+ self.assertIn("MCP request:\n" + json.dumps(
1526
+ request, indent=2), check["message"])
1527
+ self.assertIn("MCP response:\n" + json.dumps(
1528
+ response, indent=2), check["message"])
1529
+
1530
+ def test_a_non_json_failure_is_not_truncated(self) -> None:
1531
+ with tempfile.TemporaryDirectory() as temporary_directory:
1532
+ service = self._service(Path(temporary_directory))
1533
+ response = "x" * 500
1534
+
1535
+ check = self._run(service, response)
1536
+
1537
+ self.assertTrue(check["message"].endswith(response))
1538
+
1487
1539
  def test_an_answer_that_is_not_the_asked_for_json_fails_the_check(self) -> None:
1488
1540
  # An agent that ignored the format can't be believed about the rest.
1489
1541
  with tempfile.TemporaryDirectory() as temporary_directory:
@@ -1558,7 +1610,8 @@ class SetupTasksMcpTest(unittest.TestCase):
1558
1610
  service.root = root
1559
1611
  service.data_dir = root / ".codee"
1560
1612
  service.data_dir.mkdir()
1561
- settings = Settings(credentials={"jira": {"base_url": "https://stale.test"}})
1613
+ settings = Settings(
1614
+ credentials={"jira": {"base_url": "https://stale.test"}})
1562
1615
  service.context = CodeeMainContext(
1563
1616
  data_dir=service.data_dir, settings=settings)
1564
1617
  save_settings(service.data_dir, settings)
@@ -1572,7 +1625,8 @@ class SetupTasksMcpTest(unittest.TestCase):
1572
1625
  root = Path(temporary_directory)
1573
1626
  service = self._service(root)
1574
1627
 
1575
- done, message = service.setup_tasks_mcp("jira", self.JIRA_CREDENTIALS)
1628
+ done, message = service.setup_tasks_mcp(
1629
+ "jira", self.JIRA_CREDENTIALS)
1576
1630
 
1577
1631
  self.assertTrue(done, message)
1578
1632
  config = self._config(root)
@@ -1670,7 +1724,8 @@ class SetupTasksMcpTest(unittest.TestCase):
1670
1724
  service = self._service(root)
1671
1725
  (root / ".mcp.json").write_text("{not json")
1672
1726
 
1673
- done, message = service.setup_tasks_mcp("jira", self.JIRA_CREDENTIALS)
1727
+ done, message = service.setup_tasks_mcp(
1728
+ "jira", self.JIRA_CREDENTIALS)
1674
1729
 
1675
1730
  self.assertFalse(done)
1676
1731
  self.assertIn("not valid JSON", message)
@@ -146,7 +146,7 @@ class AbstractTasksProvider(ABC):
146
146
  """
147
147
  if not tasks:
148
148
  return (f"Connected to {self.describe()}. "
149
- "No tasks are assigned to it right now.")
149
+ "No tasks are waiting for it right now.")
150
150
  preview = ", ".join(f"{task.key} {task.summary}" for task in tasks[:3])
151
151
  more = f", +{len(tasks) - 3} more" if len(tasks) > 3 else ""
152
152
  return (f"Connected to {self.describe()}. "
@@ -1,7 +1,7 @@
1
1
  """Read-only Azure DevOps tasks provider, authenticated through Entra ID.
2
2
 
3
- Every call here is a read: a WIQL query for the ids assigned to the connected
4
- account, then a batch fetch of those work items. The WIQL endpoint is a POST,
3
+ Every call here is a read: a WIQL query for the ids of the Codee work items in
4
+ the states the skills trigger on, then a batch fetch of those work items. The WIQL endpoint is a POST,
5
5
  but it is a query — nothing in this module creates or modifies a work item.
6
6
  """
7
7
  from urllib.parse import quote
@@ -36,8 +36,8 @@ _FIELDS = [
36
36
  # Which Azure DevOps work item types Codee picks up, and what each stands for,
37
37
  # is configured per installation in Settings — organizations disagree on the
38
38
  # names ("User Story" in Agile, "Product Backlog Item" in Scrum, anything at
39
- # all in a custom process). Anything else assigned to the connected account
40
- # belongs to a human and is left alone.
39
+ # all in a custom process). A work item of any other type belongs to a human
40
+ # and is left alone, whatever state it reaches.
41
41
 
42
42
  # Azure DevOps priority is 1-4 with 1 highest; the executor logs this next to
43
43
  # JIRA-style names, so translate rather than print a bare digit.
@@ -122,7 +122,7 @@ class AzureDevOpsWorkItem(Task):
122
122
 
123
123
 
124
124
  class AzureDevOpsTasksProvider(AbstractTasksProvider):
125
- """Fetches work items assigned to the connected account as provider-agnostic Tasks."""
125
+ """Fetches the organization's Codee work items as provider-agnostic Tasks."""
126
126
 
127
127
  DISPLAY_NAME = "Azure DevOps"
128
128
  MCP_SERVER_NAME = "ado"
@@ -157,11 +157,21 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
157
157
  connection = self._auth.connection() or {}
158
158
  account = connection.get("account") or "connected account"
159
159
  types = ", ".join(self._work_item_types.values()) or "no work item types"
160
+ # The account is named as the identity the query runs as, not as a
161
+ # filter — the poll matches on type and state, whoever a work item is
162
+ # assigned to.
160
163
  # The filter only gets a mention when there is one: it is off for most
161
164
  # installations, and "filter none" reads like a setting gone wrong.
162
165
  extra = f", filter {self._task_filter}" if self._task_filter else ""
163
166
  return (f"Azure DevOps {self._config.organization_url} "
164
- f"(all projects, assignee {account}, types {types}{extra})")
167
+ f"(all projects, connected as {account}, types {types}{extra})")
168
+
169
+ def verify_connection(self, statuses: list[str]) -> tuple[bool, str]:
170
+ """Pull tasks and include the exact WIQL in a successful check."""
171
+ verified, message = super().verify_connection(statuses)
172
+ if not verified:
173
+ return verified, message
174
+ return verified, f"{message}\n\nWIQL: {self._build_wiql(statuses)}"
165
175
 
166
176
  def mcp_server(self) -> McpServer | None:
167
177
  """Microsoft's Azure DevOps MCP server, addressed at this organization.
@@ -204,7 +214,7 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
204
214
  return [
205
215
  f'Create a new "{item_type}" work item in the {organization} '
206
216
  "organization, in any project you can create work items in, with "
207
- f'the title "{summary}", assigned to {account}.',
217
+ f'the title "{summary}".',
208
218
  "Move that work item to a Done, Closed or Removed state — "
209
219
  "whichever its board offers — so it does not stay open.",
210
220
  ]
@@ -280,7 +290,7 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
280
290
 
281
291
  def get_tasks(self, statuses: list[str],
282
292
  raise_errors: bool = False) -> list[Task]:
283
- """Fetch work items assigned to the connected account in the given states."""
293
+ """Fetch the Codee work items sitting in the given states."""
284
294
  # Nothing is waiting on a work item, so there is no request worth
285
295
  # making. The settings check passes no statuses too, but there the whole
286
296
  # point is to reach Azure DevOps, so it queries without a state filter.
@@ -342,29 +352,39 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
342
352
  """The state filter, dropped entirely when nothing was requested.
343
353
 
344
354
  ``IN ()`` is not valid WIQL, and the only caller that passes no statuses
345
- is the connection check — it wants every Codee work item assigned to the
346
- account, whatever state it sits in.
355
+ is the connection check — it wants every Codee work item it can see,
356
+ whatever state it sits in.
347
357
  """
348
358
  if not statuses:
349
359
  return ""
350
360
  quoted = ", ".join(_quote_wiql(status) for status in statuses)
351
- return f"AND [System.State] IN ({quoted}) "
361
+ return f"[System.State] IN ({quoted})"
352
362
 
353
363
  def _build_wiql(self, statuses: list[str]) -> str:
354
- """WIQL for Codee work items owned by the connected account, highest priority first.
355
-
356
- Nothing here names a project, and no ``@project`` macro is used — that
357
- is what keeps the query valid with no project in the route, so it spans
358
- the organization. What comes back is still narrow: only the Codee work
359
- item types, only the states asked for, and only what is assigned to the
360
- connected account.
364
+ """WIQL for Codee work items, highest priority first, then oldest.
365
+
366
+ There is no assignee clause: what hands a work item to Codee is its
367
+ type and its state, not who it is assigned to. So a work item a human
368
+ still owns is picked up the moment it reaches a state one of the skills
369
+ triggers on, which is what makes those states the handover — they have
370
+ to be ones only Codee's workflow uses. An installation that does want
371
+ an owner filter writes one as the custom WIQL in Settings, e.g.
372
+ ``[System.AssignedTo] = @Me``.
373
+
374
+ Nothing here names a project either, and no ``@project`` macro is used
375
+ — that is what keeps the query valid with no project in the route, so
376
+ it spans the organization. What comes back is still narrow: only the
377
+ Codee work item types, and only the states asked for.
361
378
  """
379
+ clauses = [clause for clause in (
380
+ self._build_wiql_type_clause(),
381
+ self._build_wiql_status_clause(statuses),
382
+ self._build_wiql_filter_clause(),
383
+ ) if clause]
384
+ where = f"WHERE {' AND '.join(clauses)} " if clauses else ""
362
385
  return (
363
386
  "SELECT [System.Id] FROM WorkItems "
364
- "WHERE [System.AssignedTo] = @Me "
365
- f"{self._build_wiql_type_clause()}"
366
- f"{self._build_wiql_status_clause(statuses)}"
367
- f"{self._build_wiql_filter_clause()}"
387
+ f"{where}"
368
388
  "ORDER BY [Microsoft.VSTS.Common.Priority] ASC, [System.CreatedDate] ASC"
369
389
  )
370
390
 
@@ -373,25 +393,26 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
373
393
 
374
394
  Bracketed, because a filter is a whole condition rather than a single
375
395
  term: an unparenthesized ``... OR ...`` would bind its OR across the
376
- assignee and type clauses and hand back items Codee does not own.
396
+ type and state clauses and hand back items Codee does not own.
377
397
  """
378
398
  if not self._task_filter:
379
399
  return ""
380
- return f"AND ({self._task_filter}) "
400
+ return f"({self._task_filter})"
381
401
 
382
402
  def _build_wiql_type_clause(self) -> str:
383
403
  """The work item type filter, from the work items configured in Settings.
384
404
 
385
405
  Dropped when nothing is mapped, like the state clause above and for the
386
- same reason — but this one dropping is worse than a wide query: it would
387
- hand the executor every item assigned to the account. Settings keeps the
388
- mandatory work items filled in so it cannot happen in practice.
406
+ same reason — but this one dropping is worse than a wide query: with no
407
+ assignee clause to fall back on it would hand the executor every item in
408
+ the organization. Settings keeps the mandatory work items filled in so
409
+ it cannot happen in practice.
389
410
  """
390
411
  item_types = list(self._work_item_types.values())
391
412
  if not item_types:
392
413
  return ""
393
414
  quoted = ", ".join(_quote_wiql(item_type) for item_type in item_types)
394
- return f"AND [System.WorkItemType] IN ({quoted}) "
415
+ return f"[System.WorkItemType] IN ({quoted})"
395
416
 
396
417
  def _fetch_work_items(self, token: str, ids: list[int]) -> list[dict]:
397
418
  """Batch-fetch the requested work items. Organization-scoped, as the API requires."""
@@ -339,9 +339,16 @@ class TasksProviderTest(unittest.TestCase):
339
339
  wiql = self.provider._build_wiql(["Ready", "Bob's queue"])
340
340
 
341
341
  self.assertIn("[System.State] IN ('Ready', 'Bob''s queue')", wiql)
342
- self.assertIn("[System.AssignedTo] = @Me", wiql)
343
342
  self.assertIn("ORDER BY [Microsoft.VSTS.Common.Priority] ASC", wiql)
344
343
 
344
+ def test_the_query_does_not_filter_on_an_assignee(self) -> None:
345
+ # Type and state are the handover; who the work item is assigned
346
+ # to is nobody's business but the humans working alongside it.
347
+ wiql = self.provider._build_wiql(["Ready"])
348
+
349
+ self.assertNotIn("System.AssignedTo", wiql)
350
+ self.assertNotIn("@Me", wiql)
351
+
345
352
  def test_wiql_only_asks_for_the_mapped_work_item_types(self) -> None:
346
353
  wiql = self.provider._build_wiql(["Ready"])
347
354
 
@@ -534,7 +541,7 @@ class TasksProviderTest(unittest.TestCase):
534
541
  wiql = self.provider._build_wiql([])
535
542
 
536
543
  self.assertNotIn("[System.State] IN", wiql)
537
- self.assertIn("[System.AssignedTo] = @Me", wiql)
544
+ self.assertIn("[System.WorkItemType] IN", wiql)
538
545
 
539
546
  def test_a_successful_pull_names_the_work_items_it_found(self) -> None:
540
547
  self._connect()
@@ -551,6 +558,31 @@ class TasksProviderTest(unittest.TestCase):
551
558
 
552
559
  self.assertTrue(verified)
553
560
  self.assertIn("11 Fix the thing", message)
561
+ self.assertIn(
562
+ "WIQL: SELECT [System.Id] FROM WorkItems "
563
+ "WHERE [System.WorkItemType] IN ('User Story', 'Task') "
564
+ "AND [System.State] IN ('Ready') "
565
+ "ORDER BY [Microsoft.VSTS.Common.Priority] ASC, "
566
+ "[System.CreatedDate] ASC",
567
+ message)
568
+
569
+ def test_an_empty_check_prints_the_custom_filter_in_the_raw_wiql(self) -> None:
570
+ self._connect()
571
+ settings = _settings(organization_url="https://dev.azure.com/acme",
572
+ client_id="client-1", client_secret="secret-1")
573
+ settings.task_filters = {
574
+ "azure_devops": "[Custom.Assistant] = 'Codee Agent User Account'"}
575
+ provider = AzureDevOpsTasksProvider(settings, self.context)
576
+
577
+ with patch("codee_tasks_azure_devops.provider.requests.post",
578
+ return_value=_response({"workItems": []})):
579
+ verified, message = provider.verify_connection(["Ready"])
580
+
581
+ self.assertTrue(verified)
582
+ self.assertIn("No tasks are waiting for it right now.", message)
583
+ self.assertIn(
584
+ "AND ([Custom.Assistant] = 'Codee Agent User Account') ORDER BY",
585
+ message)
554
586
 
555
587
  def test_a_rejected_query_reports_what_azure_devops_said(self) -> None:
556
588
  self._connect()
@@ -614,7 +646,8 @@ class AzureDevOpsMcpTest(unittest.TestCase):
614
646
  self.assertIn('"Task" work item', steps[0])
615
647
  self.assertIn("acme organization", steps[0])
616
648
  self.assertIn('title "Codee check 1234"', steps[0])
617
- self.assertIn("assigned to dev@acme.com", steps[0])
649
+ # Nothing is said about the assignee: the poll does not filter on one.
650
+ self.assertNotIn("assigned to", steps[0])
618
651
  self.assertIn("Done, Closed or Removed", steps[1])
619
652
 
620
653
  def test_no_check_steps_before_the_account_is_known(self) -> None:
@@ -174,7 +174,7 @@ class JiraTasksProvider(AbstractTasksProvider):
174
174
 
175
175
  def get_tasks(self, statuses: list[str],
176
176
  raise_errors: bool = False) -> list[Task]:
177
- """Fetch tasks assigned to the target user in the configured states."""
177
+ """Fetch the Codee issues sitting in the configured statuses."""
178
178
  # Nothing is waiting on an issue, so there is no request worth making.
179
179
  # The settings check passes no statuses too, but there the whole point
180
180
  # is to reach JIRA, so it queries without a status filter.
File without changes
File without changes