codee-agent 0.1.0__tar.gz → 0.2.0__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 (68) hide show
  1. {codee_agent-0.1.0 → codee_agent-0.2.0}/PKG-INFO +1 -1
  2. {codee_agent-0.1.0 → codee_agent-0.2.0}/pyproject.toml +1 -1
  3. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/admin.py +7 -12
  4. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/admin_service.py +2 -2
  5. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/test_trigger_cron_skills.py +32 -0
  6. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/trigger_aws_sqs_skills.py +27 -1
  7. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/test_admin_service.py +0 -1
  8. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/test_executor.py +2 -2
  9. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_main_context/context.py +2 -1
  10. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_tasks_azure_devops/oauth.py +8 -5
  11. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_tasks_azure_devops/provider.py +13 -3
  12. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_tasks_azure_devops/test.py +42 -18
  13. {codee_agent-0.1.0 → codee_agent-0.2.0}/LICENSE +0 -0
  14. {codee_agent-0.1.0 → codee_agent-0.2.0}/README.md +0 -0
  15. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/.gitignore +0 -0
  16. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/__init__.py +0 -0
  17. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/admin_api.py +0 -0
  18. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/admin_cli.py +0 -0
  19. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/executor.py +0 -0
  20. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/init_cli.py +0 -0
  21. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/__init__.py +0 -0
  22. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/cron_describe.py +0 -0
  23. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/runs_db.py +0 -0
  24. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/test_runs_db.py +0 -0
  25. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  26. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/trigger_cron_skills.py +0 -0
  27. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/trigger_email_skills.py +0 -0
  28. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/lib/trigger_issue_skills.py +0 -0
  29. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/mail_server.py +0 -0
  30. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/start_cli.py +0 -0
  31. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/AGENTS.md +0 -0
  32. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/CLAUDE.md +0 -0
  33. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  34. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  35. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  36. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  37. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  38. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  39. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  40. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  41. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  42. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/test_admin_api.py +0 -0
  43. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/test_admin_cli.py +0 -0
  44. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/test_init_cli.py +0 -0
  45. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/test_memory_index.py +0 -0
  46. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/test_start_cli.py +0 -0
  47. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee/workflow_graph.py +0 -0
  48. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_admin/__init__.py +0 -0
  49. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_admin/codee_admin.py +0 -0
  50. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_agent_abstract/__init__.py +0 -0
  51. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_agent_abstract/provider.py +0 -0
  52. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_agent_claude_code/__init__.py +0 -0
  53. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_agent_claude_code/provider.py +0 -0
  54. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_agent_github_copilot/__init__.py +0 -0
  55. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_agent_github_copilot/provider.py +0 -0
  56. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_agent_github_copilot/test.py +0 -0
  57. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_database/__init__.py +0 -0
  58. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_database/database.py +0 -0
  59. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_database/oauth_tokens.py +0 -0
  60. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_main_context/__init__.py +0 -0
  61. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_main_context/logging.py +0 -0
  62. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_main_context/test_logging.py +0 -0
  63. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_tasks_abstract/__init__.py +0 -0
  64. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_tasks_abstract/provider.py +0 -0
  65. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
  66. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_tasks_jira/__init__.py +0 -0
  67. {codee_agent-0.1.0 → codee_agent-0.2.0}/src/codee_tasks_jira/provider.py +0 -0
  68. {codee_agent-0.1.0 → codee_agent-0.2.0}/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.1.0
3
+ Version: 0.2.0
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.1.0"
3
+ version = "0.2.0"
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"
@@ -126,7 +126,6 @@ class AdminState(rx.State):
126
126
  jira_api_token: str = ""
127
127
  jira_project: str = ""
128
128
  azure_organization_url: str = ""
129
- azure_project: str = ""
130
129
  azure_tenant_id: str = ""
131
130
  azure_client_id: str = ""
132
131
  azure_client_secret: str = ""
@@ -484,7 +483,6 @@ class AdminState(rx.State):
484
483
  self.jira_api_token = jira.get("api_token", "")
485
484
  self.jira_project = jira.get("project", "")
486
485
  self.azure_organization_url = azure.get("organization_url", "")
487
- self.azure_project = azure.get("project", "")
488
486
  self.azure_tenant_id = azure.get("tenant_id", "")
489
487
  self.azure_client_id = azure.get("client_id", "")
490
488
  self.azure_client_secret = azure.get("client_secret", "")
@@ -533,9 +531,6 @@ class AdminState(rx.State):
533
531
  def set_azure_organization_url(self, value: str) -> None:
534
532
  self.azure_organization_url = value
535
533
 
536
- def set_azure_project(self, value: str) -> None:
537
- self.azure_project = value
538
-
539
534
  def set_azure_tenant_id(self, value: str) -> None:
540
535
  self.azure_tenant_id = value
541
536
 
@@ -549,7 +544,7 @@ class AdminState(rx.State):
549
544
  def azure_can_connect(self) -> bool:
550
545
  """Everything the authorization request and the later queries need."""
551
546
  return all(value.strip() for value in (
552
- self.azure_organization_url, self.azure_project,
547
+ self.azure_organization_url,
553
548
  self.azure_client_id, self.azure_client_secret))
554
549
 
555
550
  def connect_azure_devops(self) -> Any:
@@ -560,7 +555,7 @@ class AdminState(rx.State):
560
555
  """
561
556
  if not self.azure_can_connect:
562
557
  return rx.toast.error(
563
- "Fill in organization URL, project, client ID and client secret first.")
558
+ "Fill in organization URL, client ID and client secret first.")
564
559
  error = self._persist_settings()
565
560
  if error:
566
561
  return rx.toast.error(error)
@@ -591,7 +586,6 @@ class AdminState(rx.State):
591
586
  if self.tasks_provider == "jira"
592
587
  else {
593
588
  "organization_url": self.azure_organization_url,
594
- "project": self.azure_project,
595
589
  "tenant_id": self.azure_tenant_id,
596
590
  "client_id": self.azure_client_id,
597
591
  "client_secret": self.azure_client_secret,
@@ -1443,7 +1437,7 @@ def azure_instructions() -> rx.Component:
1443
1437
  "→ user_impersonation → Add. Entra publishes no read-only scope "
1444
1438
  "for Azure DevOps; Codee only ever issues read calls, and you can "
1445
1439
  "narrow it further by connecting with an account that has Readers "
1446
- "access to the project."),
1440
+ "access to only the projects it should see."),
1447
1441
  azure_step(4, "Create a client secret",
1448
1442
  "Certificates & secrets → New client secret. Copy the Value "
1449
1443
  "column immediately — Azure hides it once you leave the page."),
@@ -1485,9 +1479,10 @@ def azure_fields() -> rx.Component:
1485
1479
  field("Organization URL", rx.input(value=AdminState.azure_organization_url,
1486
1480
  on_change=AdminState.set_azure_organization_url,
1487
1481
  placeholder="https://dev.azure.com/your-org",
1488
- width="100%")),
1489
- field("Project", rx.input(value=AdminState.azure_project,
1490
- on_change=AdminState.set_azure_project, width="100%")),
1482
+ width="100%"),
1483
+ hint=rx.text("Work items are picked up from every project in this "
1484
+ "organization the connected account can read.",
1485
+ color=MUTED, font_size="0.8rem")),
1491
1486
  field("Application (client) ID", rx.input(value=AdminState.azure_client_id,
1492
1487
  on_change=AdminState.set_azure_client_id,
1493
1488
  width="100%")),
@@ -949,8 +949,8 @@ class AdminService:
949
949
  """
950
950
  config = azure_oauth.OAuthConfig.from_settings(self.load_settings())
951
951
  if not config.is_complete():
952
- return False, ("Fill in organization URL, project, client ID and "
953
- "client secret before connecting.")
952
+ return False, ("Fill in organization URL, client ID and client "
953
+ "secret before connecting.")
954
954
  state = azure_oauth.new_state()
955
955
  code_verifier = azure_oauth.new_code_verifier()
956
956
  redirect_uri = self.azure_redirect_uri()
@@ -6,11 +6,13 @@ import unittest
6
6
  import sys
7
7
  from datetime import datetime, timedelta
8
8
  from pathlib import Path
9
+ from unittest.mock import patch
9
10
 
10
11
  sys.path.insert(0, str(Path(__file__).parent))
11
12
 
12
13
  from codee_main_context.context import CodeeMainContext
13
14
 
15
+ from codee.lib import trigger_aws_sqs_skills as trigger_aws_sqs_skills_module
14
16
  from codee.lib.trigger_aws_sqs_skills import (
15
17
  AwsSqsMessage,
16
18
  find_aws_sqs_triggered_skills,
@@ -480,6 +482,36 @@ class CronSkillTests(unittest.TestCase):
480
482
  self.assertTrue(calls[0][1])
481
483
  self.assertEqual(sqs_source.deleted, [message])
482
484
 
485
+ def test_reconcile_survives_unconfigured_aws_client(self):
486
+ def run_claude(user_message: str, session_id: str, model: str = "") -> str:
487
+ raise AssertionError("should not run without an SQS client")
488
+
489
+ with tempfile.TemporaryDirectory() as temp_dir:
490
+ root = Path(temp_dir)
491
+ skills_dir = root / "skills"
492
+ skill_dir = skills_dir / "sqs-check"
493
+ skill_dir.mkdir(parents=True)
494
+ (skill_dir / "SKILL.md").write_text(
495
+ "---\n"
496
+ "name: SQS Check\n"
497
+ "disable-model-invocation: true\n"
498
+ "x-codee-trigger: aws-sqs\n"
499
+ "x-codee-aws-sqs-queue: codee-queue\n"
500
+ "---\n\n"
501
+ "Process this content: {CONTENT}\n"
502
+ )
503
+
504
+ with patch.object(
505
+ trigger_aws_sqs_skills_module,
506
+ "Boto3AwsSqsMessageSource",
507
+ side_effect=Exception("You must specify a region."),
508
+ ):
509
+ trigger_aws_sqs_skills(
510
+ run_claude,
511
+ skills_dir=skills_dir,
512
+ main_context=_ctx(root),
513
+ )
514
+
483
515
 
484
516
  if __name__ == "__main__":
485
517
  unittest.main()
@@ -53,7 +53,9 @@ def trigger_aws_sqs_skills(
53
53
  return
54
54
 
55
55
  if sqs_message_source is None:
56
- sqs_message_source = Boto3AwsSqsMessageSource()
56
+ sqs_message_source = _build_boto3_message_source()
57
+ if sqs_message_source is None:
58
+ return
57
59
 
58
60
  for skill in skills:
59
61
  try:
@@ -131,6 +133,30 @@ def find_aws_sqs_triggered_skills(skills_dir: Path = SKILLS_DIR) -> list[AwsSqsT
131
133
  return skills
132
134
 
133
135
 
136
+ def _build_boto3_message_source() -> "Boto3AwsSqsMessageSource | None":
137
+ """Build the SQS client, or return None when AWS isn't configured.
138
+
139
+ boto3 raises (NoRegionError, missing credentials, boto3 not installed) at
140
+ client construction. That must not abort the executor tick -- the rest of
141
+ run_once() still has cron skills, email skills and tasks to process -- so we
142
+ report it once per process and let SQS triggers stay idle.
143
+ """
144
+ global _boto3_unavailable_reported
145
+ try:
146
+ return Boto3AwsSqsMessageSource()
147
+ except Exception as exc:
148
+ if not _boto3_unavailable_reported:
149
+ _boto3_unavailable_reported = True
150
+ print(
151
+ f"[aws_sqs_skills] AWS SQS client unavailable ({exc}); SQS triggers "
152
+ "stay idle until AWS is configured (e.g. AWS_DEFAULT_REGION)."
153
+ )
154
+ return None
155
+
156
+
157
+ _boto3_unavailable_reported = False
158
+
159
+
134
160
  class Boto3AwsSqsMessageSource:
135
161
  def __init__(self, *, visibility_timeout: int = 7200) -> None:
136
162
  import boto3
@@ -781,7 +781,6 @@ class AzureDevOpsOAuthTest(unittest.TestCase):
781
781
 
782
782
  AZURE_CREDENTIALS = {
783
783
  "organization_url": "https://dev.azure.com/acme",
784
- "project": "Core",
785
784
  "tenant_id": "tenant-1",
786
785
  "client_id": "client-1",
787
786
  "client_secret": "secret-1",
@@ -55,7 +55,6 @@ class RefreshConfigTest(unittest.TestCase):
55
55
  tasks_provider=TasksProvider.AZURE_DEVOPS,
56
56
  credentials={TasksProvider.AZURE_DEVOPS.value: {
57
57
  "organization_url": "https://dev.azure.com/acme",
58
- "project": "Core",
59
58
  "tenant_id": "tenant-1",
60
59
  "client_id": "client-1",
61
60
  "client_secret": "secret-1",
@@ -104,7 +103,8 @@ class RefreshConfigTest(unittest.TestCase):
104
103
 
105
104
  self._save(
106
105
  tasks_provider=TasksProvider.AZURE_DEVOPS,
107
- credentials={TasksProvider.AZURE_DEVOPS.value: {"project": "Core"}},
106
+ credentials={TasksProvider.AZURE_DEVOPS.value: {
107
+ "organization_url": "https://dev.azure.com/acme"}},
108
108
  )
109
109
  with patch.dict(executor._TASKS_PROVIDERS,
110
110
  {TasksProvider.AZURE_DEVOPS: _Exploding}):
@@ -59,9 +59,10 @@ TASKS_PROVIDER_FIELDS: dict[TasksProvider, list[CredentialField]] = {
59
59
  # Azure DevOps authenticates through an Entra ID app registration, so the
60
60
  # stored credentials describe the app; the tokens it yields live in SQLite
61
61
  # (codee_database.oauth_tokens) rather than in settings.json.
62
+ # There is no project field: work items are queried across the whole
63
+ # organization, which is the scope the granted access has anyway.
62
64
  TasksProvider.AZURE_DEVOPS: [
63
65
  CredentialField("organization_url", "Organization URL"),
64
- CredentialField("project", "Project"),
65
66
  CredentialField("tenant_id", "Directory (tenant) ID"),
66
67
  CredentialField("client_id", "Application (client) ID"),
67
68
  CredentialField("client_secret", "Client secret", secret=True),
@@ -64,10 +64,14 @@ class AzureDevOpsAuthError(RuntimeError):
64
64
 
65
65
  @dataclass(frozen=True)
66
66
  class OAuthConfig:
67
- """The Entra app registration, as captured in settings.json."""
67
+ """The Entra app registration, as captured in settings.json.
68
+
69
+ There is no project here: queries run across the whole organization. A
70
+ project would only ever have been a filter — the access Entra grants covers
71
+ the organization however it is set.
72
+ """
68
73
 
69
74
  organization_url: str = ""
70
- project: str = ""
71
75
  tenant_id: str = ""
72
76
  client_id: str = ""
73
77
  client_secret: str = ""
@@ -77,7 +81,6 @@ class OAuthConfig:
77
81
  creds = settings.credentials.get(PROVIDER, {})
78
82
  return cls(
79
83
  organization_url=(creds.get("organization_url") or "").strip().rstrip("/"),
80
- project=(creds.get("project") or "").strip(),
81
84
  tenant_id=(creds.get("tenant_id") or "").strip(),
82
85
  client_id=(creds.get("client_id") or "").strip(),
83
86
  client_secret=(creds.get("client_secret") or "").strip(),
@@ -85,8 +88,8 @@ class OAuthConfig:
85
88
 
86
89
  def is_complete(self) -> bool:
87
90
  """Whether we have everything needed to run the flow and query tasks."""
88
- return bool(self.organization_url and self.project
89
- and self.client_id and self.client_secret)
91
+ return bool(self.organization_url and self.client_id
92
+ and self.client_secret)
90
93
 
91
94
  @property
92
95
  def tenant(self) -> str:
@@ -91,7 +91,7 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
91
91
  connection = self._auth.connection() or {}
92
92
  account = connection.get("account") or "connected account"
93
93
  return (f"Azure DevOps {self._config.organization_url} "
94
- f"(project {self._config.project}, assignee {account})")
94
+ f"(all projects, assignee {account})")
95
95
 
96
96
  def get_tasks(self, statuses: list[str]) -> list[Task]:
97
97
  """Fetch work items assigned to the connected account in the given states."""
@@ -120,8 +120,11 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
120
120
  for item_id in ids if item_id in by_id]
121
121
 
122
122
  def _query_work_item_ids(self, token: str, statuses: list[str]) -> list[int]:
123
+ # Organization-scoped, like the batch fetch below: the endpoint's
124
+ # project segment is optional, and leaving it off is what lets one query
125
+ # span every project the connected account can read.
123
126
  response = requests.post(
124
- f"{self._config.organization_url}/{self._config.project}/_apis/wit/wiql",
127
+ f"{self._config.organization_url}/_apis/wit/wiql",
125
128
  params={"api-version": API_VERSION, "$top": _MAX_TASKS},
126
129
  json={"query": self._build_wiql(statuses)},
127
130
  headers=self._headers(token),
@@ -132,7 +135,14 @@ class AzureDevOpsTasksProvider(AbstractTasksProvider):
132
135
  return [item["id"] for item in work_items][:_BATCH_LIMIT]
133
136
 
134
137
  def _build_wiql(self, statuses: list[str]) -> str:
135
- """WIQL for Codee work items owned by the connected account, highest priority first."""
138
+ """WIQL for Codee work items owned by the connected account, highest priority first.
139
+
140
+ Nothing here names a project, and no ``@project`` macro is used — that
141
+ is what keeps the query valid with no project in the route, so it spans
142
+ the organization. What comes back is still narrow: only the Codee work
143
+ item types, only the states asked for, and only what is assigned to the
144
+ connected account.
145
+ """
136
146
  quoted_statuses = ", ".join(_quote_wiql(status) for status in statuses)
137
147
  quoted_types = ", ".join(_quote_wiql(item_type)
138
148
  for item_type in _WORK_ITEM_TYPES)
@@ -18,7 +18,6 @@ from codee_tasks_azure_devops.provider import AzureDevOpsTasksProvider
18
18
  def _config(**overrides) -> OAuthConfig:
19
19
  values = {
20
20
  "organization_url": "https://dev.azure.com/acme",
21
- "project": "Core",
22
21
  "tenant_id": "tenant-1",
23
22
  "client_id": "client-1",
24
23
  "client_secret": "secret-1",
@@ -39,16 +38,16 @@ def _iso(offset: timedelta) -> str:
39
38
  return (datetime.now(timezone.utc) + offset).isoformat()
40
39
 
41
40
 
41
+ def _settings(**credentials) -> Settings:
42
+ return Settings(tasks_provider=TasksProvider.AZURE_DEVOPS,
43
+ credentials={"azure_devops": credentials})
44
+
45
+
42
46
  class OAuthConfigTest(unittest.TestCase):
43
47
  def test_from_settings_reads_provider_credentials(self) -> None:
44
- settings = Settings(
45
- tasks_provider=TasksProvider.AZURE_DEVOPS,
46
- credentials={"azure_devops": {
47
- "organization_url": "https://dev.azure.com/acme/",
48
- "project": "Core",
49
- "client_id": "client-1",
50
- "client_secret": "secret-1",
51
- }})
48
+ settings = _settings(organization_url="https://dev.azure.com/acme/",
49
+ client_id="client-1",
50
+ client_secret="secret-1")
52
51
 
53
52
  config = OAuthConfig.from_settings(settings)
54
53
 
@@ -56,6 +55,16 @@ class OAuthConfigTest(unittest.TestCase):
56
55
  self.assertEqual(config.organization_url, "https://dev.azure.com/acme")
57
56
  self.assertTrue(config.is_complete())
58
57
 
58
+ def test_a_leftover_project_credential_is_ignored(self) -> None:
59
+ # Settings written when a project field still existed must not keep the
60
+ # queries narrowed to it; there is nothing left that reads the key.
61
+ config = OAuthConfig.from_settings(
62
+ _settings(organization_url="https://dev.azure.com/acme",
63
+ project="Core", client_id="c", client_secret="s"))
64
+
65
+ self.assertTrue(config.is_complete())
66
+ self.assertNotIn("Core", str(config))
67
+
59
68
  def test_is_incomplete_without_a_secret(self) -> None:
60
69
  self.assertFalse(_config(client_secret="").is_complete())
61
70
 
@@ -299,15 +308,11 @@ class TasksProviderTest(unittest.TestCase):
299
308
  self._temporary = tempfile.TemporaryDirectory()
300
309
  self.addCleanup(self._temporary.cleanup)
301
310
  self.context = CodeeMainContext(data_dir=Path(self._temporary.name))
302
- settings = Settings(
303
- tasks_provider=TasksProvider.AZURE_DEVOPS,
304
- credentials={"azure_devops": {
305
- "organization_url": "https://dev.azure.com/acme",
306
- "project": "Core",
307
- "client_id": "client-1",
308
- "client_secret": "secret-1",
309
- }})
310
- self.provider = AzureDevOpsTasksProvider(settings, self.context)
311
+ self.provider = AzureDevOpsTasksProvider(
312
+ _settings(organization_url="https://dev.azure.com/acme",
313
+ client_id="client-1",
314
+ client_secret="secret-1"),
315
+ self.context)
311
316
 
312
317
  def _connect(self) -> None:
313
318
  oauth_tokens.save_tokens(
@@ -328,6 +333,25 @@ class TasksProviderTest(unittest.TestCase):
328
333
  self.assertIn(
329
334
  "[System.WorkItemType] IN ('Codee Task', 'Codee Story')", wiql)
330
335
 
336
+ def test_wiql_names_no_project_at_all(self) -> None:
337
+ wiql = self.provider._build_wiql(["Ready"])
338
+
339
+ self.assertNotIn("System.TeamProject", wiql)
340
+ # @project would need a project in the route, which there isn't.
341
+ self.assertNotIn("@project", wiql)
342
+
343
+ def test_the_query_is_organization_scoped(self) -> None:
344
+ self._connect()
345
+ with patch("codee_tasks_azure_devops.provider.requests.post",
346
+ side_effect=[_response({"workItems": []})]) as post:
347
+ self.provider.get_tasks(["Ready"])
348
+
349
+ self.assertEqual(post.call_args.args[0],
350
+ "https://dev.azure.com/acme/_apis/wit/wiql")
351
+
352
+ def test_describe_says_the_query_covers_all_projects(self) -> None:
353
+ self.assertIn("all projects", self.provider.describe())
354
+
331
355
  def test_not_configured_until_oauth_completes(self) -> None:
332
356
  self.assertFalse(self.provider.is_configured())
333
357
  self._connect()
File without changes
File without changes