codee-agent 0.1.1__tar.gz → 0.3.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.1 → codee_agent-0.3.0}/PKG-INFO +1 -1
  2. {codee_agent-0.1.1 → codee_agent-0.3.0}/pyproject.toml +1 -1
  3. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/admin.py +43 -35
  4. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/admin_cli.py +2 -2
  5. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/admin_service.py +83 -9
  6. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_admin_service.py +112 -9
  7. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_executor.py +2 -2
  8. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_main_context/context.py +2 -1
  9. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/oauth.py +8 -5
  10. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/provider.py +13 -3
  11. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/test.py +42 -18
  12. {codee_agent-0.1.1 → codee_agent-0.3.0}/LICENSE +0 -0
  13. {codee_agent-0.1.1 → codee_agent-0.3.0}/README.md +0 -0
  14. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/.gitignore +0 -0
  15. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/__init__.py +0 -0
  16. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/admin_api.py +0 -0
  17. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/executor.py +0 -0
  18. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/init_cli.py +0 -0
  19. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/__init__.py +0 -0
  20. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/cron_describe.py +0 -0
  21. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/runs_db.py +0 -0
  22. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/test_runs_db.py +0 -0
  23. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  24. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  25. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  26. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/trigger_cron_skills.py +0 -0
  27. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/trigger_email_skills.py +0 -0
  28. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/trigger_issue_skills.py +0 -0
  29. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/mail_server.py +0 -0
  30. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/start_cli.py +0 -0
  31. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/AGENTS.md +0 -0
  32. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/CLAUDE.md +0 -0
  33. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  34. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  35. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  36. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  37. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  38. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  39. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  40. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  41. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  42. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_admin_api.py +0 -0
  43. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_admin_cli.py +0 -0
  44. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_init_cli.py +0 -0
  45. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_memory_index.py +0 -0
  46. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_start_cli.py +0 -0
  47. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/workflow_graph.py +0 -0
  48. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_admin/__init__.py +0 -0
  49. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_admin/codee_admin.py +0 -0
  50. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_abstract/__init__.py +0 -0
  51. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_abstract/provider.py +0 -0
  52. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_claude_code/__init__.py +0 -0
  53. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_claude_code/provider.py +0 -0
  54. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_github_copilot/__init__.py +0 -0
  55. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_github_copilot/provider.py +0 -0
  56. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_github_copilot/test.py +0 -0
  57. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_database/__init__.py +0 -0
  58. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_database/database.py +0 -0
  59. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_database/oauth_tokens.py +0 -0
  60. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_main_context/__init__.py +0 -0
  61. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_main_context/logging.py +0 -0
  62. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_main_context/test_logging.py +0 -0
  63. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_abstract/__init__.py +0 -0
  64. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_abstract/provider.py +0 -0
  65. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
  66. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_jira/__init__.py +0 -0
  67. {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_jira/provider.py +0 -0
  68. {codee_agent-0.1.1 → codee_agent-0.3.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.1
3
+ Version: 0.3.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.1"
3
+ version = "0.3.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 = ""
@@ -336,6 +335,11 @@ class AdminState(rx.State):
336
335
  return _save_toast(*SERVICE.save_agents(self.agents_content))
337
336
 
338
337
  def load_memories(self) -> None:
338
+ self.coding_agent = SERVICE.load_settings().coding_agent.value
339
+ if self.coding_agent == "github_copilot":
340
+ self.memories = []
341
+ self.selected_memory = ""
342
+ return
339
343
  self.memories = [MemoryEntry(**entry)
340
344
  for entry in SERVICE.list_memories()]
341
345
 
@@ -484,7 +488,6 @@ class AdminState(rx.State):
484
488
  self.jira_api_token = jira.get("api_token", "")
485
489
  self.jira_project = jira.get("project", "")
486
490
  self.azure_organization_url = azure.get("organization_url", "")
487
- self.azure_project = azure.get("project", "")
488
491
  self.azure_tenant_id = azure.get("tenant_id", "")
489
492
  self.azure_client_id = azure.get("client_id", "")
490
493
  self.azure_client_secret = azure.get("client_secret", "")
@@ -533,9 +536,6 @@ class AdminState(rx.State):
533
536
  def set_azure_organization_url(self, value: str) -> None:
534
537
  self.azure_organization_url = value
535
538
 
536
- def set_azure_project(self, value: str) -> None:
537
- self.azure_project = value
538
-
539
539
  def set_azure_tenant_id(self, value: str) -> None:
540
540
  self.azure_tenant_id = value
541
541
 
@@ -549,7 +549,7 @@ class AdminState(rx.State):
549
549
  def azure_can_connect(self) -> bool:
550
550
  """Everything the authorization request and the later queries need."""
551
551
  return all(value.strip() for value in (
552
- self.azure_organization_url, self.azure_project,
552
+ self.azure_organization_url,
553
553
  self.azure_client_id, self.azure_client_secret))
554
554
 
555
555
  def connect_azure_devops(self) -> Any:
@@ -560,7 +560,7 @@ class AdminState(rx.State):
560
560
  """
561
561
  if not self.azure_can_connect:
562
562
  return rx.toast.error(
563
- "Fill in organization URL, project, client ID and client secret first.")
563
+ "Fill in organization URL, client ID and client secret first.")
564
564
  error = self._persist_settings()
565
565
  if error:
566
566
  return rx.toast.error(error)
@@ -591,7 +591,6 @@ class AdminState(rx.State):
591
591
  if self.tasks_provider == "jira"
592
592
  else {
593
593
  "organization_url": self.azure_organization_url,
594
- "project": self.azure_project,
595
594
  "tenant_id": self.azure_tenant_id,
596
595
  "client_id": self.azure_client_id,
597
596
  "client_secret": self.azure_client_secret,
@@ -611,6 +610,7 @@ class AdminState(rx.State):
611
610
 
612
611
 
613
612
  ACCENT = "var(--codee-accent)"
613
+ ACCENT_DEEP = "var(--codee-accent-deep)"
614
614
  BORDER = "1px solid var(--codee-border)"
615
615
  MUTED = "var(--codee-muted)"
616
616
  SURFACE = "var(--codee-surface)"
@@ -712,23 +712,26 @@ def shell(content: rx.Component) -> rx.Component:
712
712
  color=TEXT,
713
713
  font_family="IBM Plex Sans, sans-serif",
714
714
  style={
715
- "--codee-accent": rx.color_mode_cond("#167d5a", "#4abd85"),
716
- "--codee-border": rx.color_mode_cond("#dfe4df", "#354039"),
717
- "--codee-muted": rx.color_mode_cond("#68716b", "#a1ada5"),
718
- "--codee-surface": rx.color_mode_cond("#ffffff", "#18201c"),
719
- "--codee-page-background": rx.color_mode_cond("#f3f5f3", "#101512"),
720
- "--codee-nav-background": rx.color_mode_cond("#f7f9f7", "#141b17"),
721
- "--codee-text": rx.color_mode_cond("#202721", "#edf3ef"),
722
- "--codee-hover": rx.color_mode_cond("#edf5f0", "#213029"),
723
- "--codee-active": rx.color_mode_cond("#e2efe9", "#23372e"),
724
- "--codee-grid": rx.color_mode_cond("#e4e9e5", "#2e3932"),
725
- "--codee-subtle-icon": rx.color_mode_cond("#91a097", "#718078"),
726
- "--codee-warning-background": rx.color_mode_cond("#fffbeb", "#332a13"),
727
- "--codee-warning-border": rx.color_mode_cond("#d97706", "#f59e0b"),
715
+ "--codee-accent": rx.color_mode_cond("#2f6fd6", "#7aa9f0"),
716
+ # The darker half of the two-blue brand pairing, for filled marks
717
+ # that should recede behind the interactive blue (step markers).
718
+ "--codee-accent-deep": rx.color_mode_cond("#232c9b", "#5866de"),
719
+ "--codee-border": rx.color_mode_cond("#dbe1ec", "#2b3350"),
720
+ "--codee-muted": rx.color_mode_cond("#5d687f", "#a3adc4"),
721
+ "--codee-surface": rx.color_mode_cond("#ffffff", "#171d33"),
722
+ "--codee-page-background": rx.color_mode_cond("#f2f5fb", "#0e1224"),
723
+ "--codee-nav-background": rx.color_mode_cond("#f7f9fd", "#121729"),
724
+ "--codee-text": rx.color_mode_cond("#1b2440", "#e9edf7"),
725
+ "--codee-hover": rx.color_mode_cond("#edf3fc", "#1e2742"),
726
+ "--codee-active": rx.color_mode_cond("#e0eafb", "#24304f"),
727
+ "--codee-grid": rx.color_mode_cond("#e2e7f1", "#2a3350"),
728
+ "--codee-subtle-icon": rx.color_mode_cond("#8e99b0", "#6e7a94"),
729
+ "--codee-warning-background": rx.color_mode_cond("#fff5ed", "#33200f"),
730
+ "--codee-warning-border": rx.color_mode_cond("#e26128", "#f5854a"),
728
731
  # Tint reserved for in-flight work, so a live run reads at a glance.
729
- "--codee-running-background": rx.color_mode_cond("#effaf4", "#15271f"),
732
+ "--codee-running-background": rx.color_mode_cond("#eff4fd", "#14203a"),
730
733
  "--codee-running-glow": rx.color_mode_cond(
731
- "rgba(22, 125, 90, 0.16)", "rgba(74, 189, 133, 0.18)"),
734
+ "rgba(47, 111, 214, 0.16)", "rgba(122, 169, 240, 0.18)"),
732
735
  "color_scheme": "light dark",
733
736
  },
734
737
  )
@@ -894,13 +897,13 @@ def skill_card(skill: SkillSummary) -> rx.Component:
894
897
  return rx.box(
895
898
  rx.vstack(
896
899
  rx.hstack(rx.heading(skill.name, size="4"), rx.spacer(),
897
- rx.badge(skill.type, color_scheme="green", variant="soft"), width="100%"),
900
+ rx.badge(skill.type, color_scheme="blue", variant="soft"), width="100%"),
898
901
  rx.text(skill.description, color=MUTED, font_size="0.9rem", min_height="2.7rem",
899
902
  overflow="hidden"),
900
903
  rx.cond(
901
904
  skill.issue_status != "",
902
905
  rx.hstack(
903
- rx.badge(skill.issue_type, color_scheme="green",
906
+ rx.badge(skill.issue_type, color_scheme="blue",
904
907
  variant="outline"),
905
908
  rx.icon("circle-dot", size=14, color=SUBTLE_ICON),
906
909
  rx.text(skill.issue_status, color=MUTED,
@@ -1199,8 +1202,12 @@ def memory_page() -> rx.Component:
1199
1202
  memory_row), spacing="3", width="100%"),
1200
1203
  empty_state("notebook-text", "No memories yet."))
1201
1204
  return shell(rx.vstack(page_header("Memory", "Review and maintain durable project context."),
1202
- rx.cond(AdminState.selected_memory ==
1203
- "", listing, memory_editor()),
1205
+ rx.cond(AdminState.coding_agent == "github_copilot",
1206
+ empty_state(
1207
+ "notebook-text",
1208
+ "Github Copilot currently does not support local memory"),
1209
+ rx.cond(AdminState.selected_memory ==
1210
+ "", listing, memory_editor())),
1204
1211
  align="start", width="100%"))
1205
1212
 
1206
1213
 
@@ -1245,7 +1252,7 @@ def workflow_warning(message: rx.Var) -> rx.Component:
1245
1252
  return rx.callout(
1246
1253
  message,
1247
1254
  icon="triangle-alert",
1248
- color_scheme="amber",
1255
+ color_scheme="orange",
1249
1256
  width="100%",
1250
1257
  )
1251
1258
 
@@ -1399,7 +1406,7 @@ def sessions_page() -> rx.Component:
1399
1406
 
1400
1407
  def azure_step(number: int, title: str, detail: rx.Component | str) -> rx.Component:
1401
1408
  return rx.hstack(
1402
- rx.box(str(number), color="white", background=ACCENT, min_width="1.4rem",
1409
+ rx.box(str(number), color="white", background=ACCENT_DEEP, min_width="1.4rem",
1403
1410
  height="1.4rem", display="grid", place_items="center",
1404
1411
  border_radius="50%", font_size="0.75rem", font_weight="700"),
1405
1412
  rx.vstack(rx.text(title, font_weight="600", font_size="0.9rem"),
@@ -1443,7 +1450,7 @@ def azure_instructions() -> rx.Component:
1443
1450
  "→ user_impersonation → Add. Entra publishes no read-only scope "
1444
1451
  "for Azure DevOps; Codee only ever issues read calls, and you can "
1445
1452
  "narrow it further by connecting with an account that has Readers "
1446
- "access to the project."),
1453
+ "access to only the projects it should see."),
1447
1454
  azure_step(4, "Create a client secret",
1448
1455
  "Certificates & secrets → New client secret. Copy the Value "
1449
1456
  "column immediately — Azure hides it once you leave the page."),
@@ -1485,9 +1492,10 @@ def azure_fields() -> rx.Component:
1485
1492
  field("Organization URL", rx.input(value=AdminState.azure_organization_url,
1486
1493
  on_change=AdminState.set_azure_organization_url,
1487
1494
  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%")),
1495
+ width="100%"),
1496
+ hint=rx.text("Work items are picked up from every project in this "
1497
+ "organization the connected account can read.",
1498
+ color=MUTED, font_size="0.8rem")),
1491
1499
  field("Application (client) ID", rx.input(value=AdminState.azure_client_id,
1492
1500
  on_change=AdminState.set_azure_client_id,
1493
1501
  width="100%")),
@@ -1640,10 +1648,10 @@ app = rx.App(
1640
1648
  "width": "6px",
1641
1649
  },
1642
1650
  ".workflow-route-node--forward .react-flow__handle": {
1643
- "background": "#167d5a",
1651
+ "background": "#2f6fd6",
1644
1652
  },
1645
1653
  ".workflow-route-node--return .react-flow__handle": {
1646
- "background": "#d97706",
1654
+ "background": "#e26128",
1647
1655
  },
1648
1656
  },
1649
1657
  stylesheets=[
@@ -22,8 +22,8 @@ config = rx.Config(
22
22
  plugins=[
23
23
  rx.plugins.RadixThemesPlugin(
24
24
  theme=rx.theme(
25
- accent_color="green",
26
- gray_color="sage",
25
+ accent_color="indigo",
26
+ gray_color="slate",
27
27
  radius="small",
28
28
  appearance="inherit",
29
29
  )
@@ -1,4 +1,5 @@
1
1
  """Framework-independent operations for the Codee admin UI."""
2
+ import hashlib
2
3
  import json
3
4
  import re
4
5
  import os
@@ -74,6 +75,9 @@ _CODING_AGENTS: dict[CodingAgent, type[AbstractCodingAgent]] = {
74
75
  }
75
76
  WORKFLOW_NODE_SPACING = 440
76
77
  WORKFLOW_NODE_CENTER_OFFSET = 110
78
+ # Inferring the workflow costs a coding-agent run, so the graph is kept in the
79
+ # data directory and reused by later admin processes.
80
+ WORKFLOW_CACHE_FILE = "workflow.json"
77
81
 
78
82
  # Port the admin UI listens on unless ``codee-admin --port`` says otherwise.
79
83
  # The OAuth redirect URI is built from it, and Entra ID matches redirect URIs
@@ -199,6 +203,18 @@ def _transition_values(value: Any) -> list[dict[str, str]]:
199
203
  return transitions
200
204
 
201
205
 
206
+ def _is_workflow(value: Any) -> bool:
207
+ """Whether a stored graph still has the shape the workflow page reads."""
208
+ if not isinstance(value, dict):
209
+ return False
210
+ return all(
211
+ isinstance(value.get(issue_type), dict)
212
+ and all(isinstance(value[issue_type].get(key), list)
213
+ for key in ("nodes", "edges", "warnings"))
214
+ for issue_type in ISSUE_TYPES
215
+ )
216
+
217
+
202
218
  def _remove_redundant_skill_transitions(
203
219
  transitions: list[dict[str, str]],
204
220
  ) -> list[dict[str, str]]:
@@ -247,7 +263,8 @@ class AdminService:
247
263
  self.data_dir.mkdir(parents=True, exist_ok=True)
248
264
  self.context = CodeeMainContext(data_dir=self.data_dir)
249
265
  self.context.settings = load_settings(self.data_dir)
250
- self._workflow_cache: dict[str, Any] | None = None
266
+ # Cached graph plus the skill fingerprint it was generated from.
267
+ self._workflow_cache: tuple[str, dict[str, Any]] | None = None
251
268
  self._workflow_lock = threading.Lock()
252
269
  # Asking an agent for its catalog can mean spawning its CLI, so the
253
270
  # answer is cached per agent for the life of the process.
@@ -343,16 +360,73 @@ class AdminService:
343
360
  return ""
344
361
 
345
362
  def generate_workflow(self, force: bool = False) -> dict[str, Any]:
346
- """Return cached workflows by issue type, regenerating when requested."""
363
+ """Return cached workflows by issue type, regenerating when requested.
364
+
365
+ The cache outlives the admin process: it is stored in the data
366
+ directory under the fingerprint of the skill documents the graph was
367
+ inferred from, so a restart reuses it while an edited skill still
368
+ regenerates it.
369
+ """
347
370
  workflow_lock = getattr(self, "_workflow_lock", None)
348
371
  if workflow_lock is None:
349
372
  workflow_lock = self._workflow_lock = threading.Lock()
350
373
  with workflow_lock:
351
- workflow_cache = getattr(self, "_workflow_cache", None)
352
- if not force and workflow_cache is not None:
353
- return workflow_cache
354
- self._workflow_cache = self._generate_workflow()
355
- return self._workflow_cache
374
+ fingerprint = self._workflow_fingerprint()
375
+ if not force:
376
+ cached = self._cached_workflow(fingerprint)
377
+ if cached is not None:
378
+ return cached
379
+ workflow = self._generate_workflow()
380
+ self._workflow_cache = (fingerprint, workflow)
381
+ self._store_workflow(fingerprint, workflow)
382
+ return workflow
383
+
384
+ def _workflow_fingerprint(self) -> str:
385
+ """Digest the skill documents the workflow graph is inferred from."""
386
+ digest = hashlib.sha256()
387
+ for skill in find_issue_triggered_skills(self.skills_dir):
388
+ digest.update(skill.slug.encode())
389
+ digest.update(b"\0")
390
+ digest.update(skill.path.read_bytes())
391
+ digest.update(b"\0")
392
+ return digest.hexdigest()
393
+
394
+ def _workflow_cache_path(self) -> Path:
395
+ return self.data_dir / WORKFLOW_CACHE_FILE
396
+
397
+ def _cached_workflow(self, fingerprint: str) -> dict[str, Any] | None:
398
+ """This process's graph, else the one an earlier process stored."""
399
+ cached = getattr(self, "_workflow_cache", None)
400
+ if cached is not None and cached[0] == fingerprint:
401
+ return cached[1]
402
+ try:
403
+ stored = json.loads(self._workflow_cache_path().read_text())
404
+ except FileNotFoundError:
405
+ return None
406
+ except (OSError, ValueError) as error:
407
+ print(f"[admin] Ignoring unreadable workflow cache: {error}")
408
+ return None
409
+ if not isinstance(stored, dict) or stored.get("fingerprint") != fingerprint:
410
+ return None
411
+ workflow = stored.get("workflow")
412
+ if not _is_workflow(workflow):
413
+ return None
414
+ self._workflow_cache = (fingerprint, workflow)
415
+ return workflow
416
+
417
+ def _store_workflow(self, fingerprint: str, workflow: dict[str, Any]) -> None:
418
+ """Persist the graph for the next admin process; failing is not fatal."""
419
+ path = self._workflow_cache_path()
420
+ pending = path.with_suffix(".tmp")
421
+ try:
422
+ path.parent.mkdir(parents=True, exist_ok=True)
423
+ pending.write_text(json.dumps(
424
+ {"fingerprint": fingerprint, "workflow": workflow}))
425
+ # Replace in one step so a crash mid-write cannot leave a
426
+ # half-written cache behind.
427
+ pending.replace(path)
428
+ except OSError as error:
429
+ print(f"[admin] Failed to store workflow cache: {error}")
356
430
 
357
431
  def _generate_workflow(self) -> dict[str, Any]:
358
432
  """Infer separate status graphs for story and task skills."""
@@ -949,8 +1023,8 @@ class AdminService:
949
1023
  """
950
1024
  config = azure_oauth.OAuthConfig.from_settings(self.load_settings())
951
1025
  if not config.is_complete():
952
- return False, ("Fill in organization URL, project, client ID and "
953
- "client secret before connecting.")
1026
+ return False, ("Fill in organization URL, client ID and client "
1027
+ "secret before connecting.")
954
1028
  state = azure_oauth.new_state()
955
1029
  code_verifier = azure_oauth.new_code_verifier()
956
1030
  redirect_uri = self.azure_redirect_uri()
@@ -15,6 +15,25 @@ from codee_main_context.context import (
15
15
  CodeeMainContext, CodingAgent, Settings, TasksProvider, save_settings)
16
16
 
17
17
 
18
+ def _empty_workflow() -> dict:
19
+ return {issue_type: {"nodes": [], "edges": [], "warnings": []}
20
+ for issue_type in ("story", "task")}
21
+
22
+
23
+ def _write_issue_skill(root: Path) -> Path:
24
+ """Create one issue-trigger skill, the input the workflow cache is keyed on."""
25
+ skills_dir = root / ".claude" / "skills"
26
+ skill_dir = skills_dir / "develop"
27
+ skill_dir.mkdir(parents=True)
28
+ (skill_dir / "SKILL.md").write_text(
29
+ "---\nname: develop\ndisable-model-invocation: true\n"
30
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
31
+ "x-codee-issue-type: story\n---\n"
32
+ "After implementation, move the issue to Review.\n"
33
+ )
34
+ return skills_dir
35
+
36
+
18
37
  class AdminServiceIssueTriggerTest(unittest.TestCase):
19
38
  def test_list_skills_includes_issue_status(self) -> None:
20
39
  with tempfile.TemporaryDirectory() as temporary_directory:
@@ -227,6 +246,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
227
246
  service = AdminService.__new__(AdminService)
228
247
  service.root = root
229
248
  service.skills_dir = skills_dir
249
+ service.data_dir = root / ".codee"
230
250
  service.context = Mock(settings=Settings(
231
251
  coding_agent=CodingAgent.CLAUDE_CODE))
232
252
 
@@ -315,6 +335,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
315
335
  service = AdminService.__new__(AdminService)
316
336
  service.root = root
317
337
  service.skills_dir = skills_dir
338
+ service.data_dir = root / ".codee"
318
339
  service.context = Mock(
319
340
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
320
341
 
@@ -356,6 +377,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
356
377
  service = AdminService.__new__(AdminService)
357
378
  service.root = root
358
379
  service.skills_dir = skills_dir
380
+ service.data_dir = root / ".codee"
359
381
  service.context = Mock(
360
382
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
361
383
 
@@ -406,6 +428,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
406
428
  service = AdminService.__new__(AdminService)
407
429
  service.root = root
408
430
  service.skills_dir = skills_dir
431
+ service.data_dir = root / ".codee"
409
432
  service.context = Mock(
410
433
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
411
434
 
@@ -462,6 +485,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
462
485
  service = AdminService.__new__(AdminService)
463
486
  service.root = root
464
487
  service.skills_dir = skills_dir
488
+ service.data_dir = root / ".codee"
465
489
  service.context = Mock(
466
490
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
467
491
 
@@ -501,6 +525,7 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
501
525
  service = AdminService.__new__(AdminService)
502
526
  service.root = root
503
527
  service.skills_dir = skills_dir
528
+ service.data_dir = root / ".codee"
504
529
  service.context = Mock(
505
530
  settings=Settings(coding_agent=CodingAgent.CLAUDE_CODE))
506
531
 
@@ -518,8 +543,10 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
518
543
 
519
544
  def test_generate_workflow_returns_empty_without_issue_skills(self) -> None:
520
545
  with tempfile.TemporaryDirectory() as temporary_directory:
546
+ root = Path(temporary_directory)
521
547
  service = AdminService.__new__(AdminService)
522
- service.skills_dir = Path(temporary_directory)
548
+ service.skills_dir = root
549
+ service.data_dir = root / ".codee"
523
550
 
524
551
  self.assertEqual(service.generate_workflow(), {
525
552
  "story": {"nodes": [], "edges": [], "warnings": []},
@@ -527,15 +554,92 @@ class AdminServiceIssueTriggerTest(unittest.TestCase):
527
554
  })
528
555
 
529
556
  def test_generate_workflow_is_cached_until_forced(self) -> None:
530
- service = AdminService.__new__(AdminService)
531
- generated = {"nodes": [], "edges": [], "warnings": []}
557
+ with tempfile.TemporaryDirectory() as temporary_directory:
558
+ root = Path(temporary_directory)
559
+ service = AdminService.__new__(AdminService)
560
+ service.skills_dir = root / ".claude" / "skills"
561
+ service.data_dir = root / ".codee"
562
+ generated = _empty_workflow()
563
+
564
+ with patch.object(service, "_generate_workflow",
565
+ return_value=generated) as generate:
566
+ self.assertIs(service.generate_workflow(), generated)
567
+ self.assertIs(service.generate_workflow(), generated)
568
+ self.assertIs(service.generate_workflow(force=True), generated)
569
+
570
+ self.assertEqual(generate.call_count, 2)
571
+
572
+ def test_generate_workflow_reuses_graph_stored_by_an_earlier_process(self) -> None:
573
+ with tempfile.TemporaryDirectory() as temporary_directory:
574
+ root = Path(temporary_directory)
575
+ skills_dir = _write_issue_skill(root)
576
+ generated = _empty_workflow()
577
+ generated["story"]["warnings"] = ["stored"]
578
+
579
+ first = AdminService.__new__(AdminService)
580
+ first.skills_dir = skills_dir
581
+ first.data_dir = root / ".codee"
582
+ with patch.object(first, "_generate_workflow", return_value=generated):
583
+ first.generate_workflow()
584
+
585
+ # A restart starts from an empty in-memory cache, so only the file
586
+ # written above can spare it another coding-agent run.
587
+ restarted = AdminService.__new__(AdminService)
588
+ restarted.skills_dir = skills_dir
589
+ restarted.data_dir = root / ".codee"
590
+ with patch.object(restarted, "_generate_workflow") as generate:
591
+ workflow = restarted.generate_workflow()
532
592
 
533
- with patch.object(service, "_generate_workflow", return_value=generated) as generate:
534
- self.assertIs(service.generate_workflow(), generated)
535
- self.assertIs(service.generate_workflow(), generated)
536
- self.assertIs(service.generate_workflow(force=True), generated)
593
+ generate.assert_not_called()
594
+ self.assertEqual(workflow["story"]["warnings"], ["stored"])
595
+
596
+ def test_generate_workflow_ignores_stored_graph_after_a_skill_changes(self) -> None:
597
+ with tempfile.TemporaryDirectory() as temporary_directory:
598
+ root = Path(temporary_directory)
599
+ skills_dir = _write_issue_skill(root)
600
+
601
+ first = AdminService.__new__(AdminService)
602
+ first.skills_dir = skills_dir
603
+ first.data_dir = root / ".codee"
604
+ with patch.object(first, "_generate_workflow",
605
+ return_value=_empty_workflow()):
606
+ first.generate_workflow()
607
+
608
+ (skills_dir / "develop" / "SKILL.md").write_text(
609
+ "---\nname: develop\ndisable-model-invocation: true\n"
610
+ "x-codee-trigger: issue\nx-codee-issue-status: [Ready]\n"
611
+ "x-codee-issue-type: story\n---\n"
612
+ "After implementation, move the issue to Done.\n"
613
+ )
614
+ regenerated = _empty_workflow()
615
+ regenerated["story"]["warnings"] = ["regenerated"]
616
+ restarted = AdminService.__new__(AdminService)
617
+ restarted.skills_dir = skills_dir
618
+ restarted.data_dir = root / ".codee"
619
+ with patch.object(restarted, "_generate_workflow",
620
+ return_value=regenerated) as generate:
621
+ workflow = restarted.generate_workflow()
622
+
623
+ generate.assert_called_once_with()
624
+ self.assertEqual(workflow["story"]["warnings"], ["regenerated"])
625
+
626
+ def test_generate_workflow_ignores_a_corrupt_stored_graph(self) -> None:
627
+ with tempfile.TemporaryDirectory() as temporary_directory:
628
+ root = Path(temporary_directory)
629
+ skills_dir = _write_issue_skill(root)
630
+ data_dir = root / ".codee"
631
+ data_dir.mkdir()
632
+ (data_dir / "workflow.json").write_text("{ not json")
633
+
634
+ service = AdminService.__new__(AdminService)
635
+ service.skills_dir = skills_dir
636
+ service.data_dir = data_dir
637
+ generated = _empty_workflow()
638
+ with patch.object(service, "_generate_workflow",
639
+ return_value=generated) as generate:
640
+ self.assertIs(service.generate_workflow(), generated)
537
641
 
538
- self.assertEqual(generate.call_count, 2)
642
+ generate.assert_called_once_with()
539
643
 
540
644
 
541
645
  class AdminServiceSkillModelTest(unittest.TestCase):
@@ -781,7 +885,6 @@ class AzureDevOpsOAuthTest(unittest.TestCase):
781
885
 
782
886
  AZURE_CREDENTIALS = {
783
887
  "organization_url": "https://dev.azure.com/acme",
784
- "project": "Core",
785
888
  "tenant_id": "tenant-1",
786
889
  "client_id": "client-1",
787
890
  "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