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.
- {codee_agent-0.1.1 → codee_agent-0.3.0}/PKG-INFO +1 -1
- {codee_agent-0.1.1 → codee_agent-0.3.0}/pyproject.toml +1 -1
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/admin.py +43 -35
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/admin_cli.py +2 -2
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/admin_service.py +83 -9
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_admin_service.py +112 -9
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_executor.py +2 -2
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_main_context/context.py +2 -1
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/oauth.py +8 -5
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/provider.py +13 -3
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/test.py +42 -18
- {codee_agent-0.1.1 → codee_agent-0.3.0}/LICENSE +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/README.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/.gitignore +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/admin_api.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/executor.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/init_cli.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/cron_describe.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/runs_db.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/test_runs_db.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/test_trigger_cron_skills.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/trigger_cron_skills.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/trigger_email_skills.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/lib/trigger_issue_skills.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/mail_server.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/start_cli.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/AGENTS.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/CLAUDE.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_admin_api.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_admin_cli.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_init_cli.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_memory_index.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/test_start_cli.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/workflow_graph.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_admin/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_admin/codee_admin.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_abstract/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_abstract/provider.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_claude_code/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_claude_code/provider.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_github_copilot/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_github_copilot/provider.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_agent_github_copilot/test.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_database/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_database/database.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_database/oauth_tokens.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_main_context/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_main_context/logging.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_main_context/test_logging.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_abstract/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_abstract/provider.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_jira/__init__.py +0 -0
- {codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee_tasks_jira/provider.py +0 -0
- {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.
|
|
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
|
|
@@ -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,
|
|
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,
|
|
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("#
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
"--codee-
|
|
719
|
-
"--codee-
|
|
720
|
-
"--codee-
|
|
721
|
-
"--codee-
|
|
722
|
-
"--codee-
|
|
723
|
-
"--codee-
|
|
724
|
-
"--codee-
|
|
725
|
-
"--codee-
|
|
726
|
-
"--codee-
|
|
727
|
-
"--codee-
|
|
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("#
|
|
732
|
+
"--codee-running-background": rx.color_mode_cond("#eff4fd", "#14203a"),
|
|
730
733
|
"--codee-running-glow": rx.color_mode_cond(
|
|
731
|
-
"rgba(
|
|
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="
|
|
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="
|
|
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.
|
|
1203
|
-
|
|
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="
|
|
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=
|
|
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
|
|
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
|
-
|
|
1490
|
-
|
|
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": "#
|
|
1651
|
+
"background": "#2f6fd6",
|
|
1644
1652
|
},
|
|
1645
1653
|
".workflow-route-node--return .react-flow__handle": {
|
|
1646
|
-
"background": "#
|
|
1654
|
+
"background": "#e26128",
|
|
1647
1655
|
},
|
|
1648
1656
|
},
|
|
1649
1657
|
stylesheets=[
|
|
@@ -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
|
-
|
|
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
|
-
|
|
352
|
-
if not force
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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,
|
|
953
|
-
"
|
|
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 =
|
|
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
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
self.
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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: {
|
|
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.
|
|
89
|
-
and self.
|
|
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"(
|
|
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}/
|
|
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 =
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.1.1 → codee_agent-0.3.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|