workforge 2.4.1__py3-none-any.whl

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.
workforge/config.py ADDED
@@ -0,0 +1,47 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ import yaml
5
+ from dotenv import dotenv_values
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class WorkspaceDefaults(BaseModel):
10
+ source: str = "manual"
11
+ namespace: str = "default"
12
+ dry_run: bool = True
13
+
14
+
15
+ class WorkspaceConfig(BaseModel):
16
+ name: str
17
+ default_provider: str = "trello"
18
+ providers: dict[str, dict[str, Any]] = Field(default_factory=dict)
19
+ defaults: WorkspaceDefaults = Field(default_factory=WorkspaceDefaults)
20
+
21
+
22
+ class WorkspaceRuntime(BaseModel):
23
+ path: Path
24
+ config: WorkspaceConfig
25
+ env: dict[str, str] = Field(default_factory=dict)
26
+
27
+
28
+ def load_workspace_config(workspace_path: Path) -> WorkspaceConfig:
29
+ return load_workspace(workspace_path).config
30
+
31
+
32
+ def load_workspace(workspace_path: Path) -> WorkspaceRuntime:
33
+ config_path = workspace_path / "workforge.yaml"
34
+ if not config_path.exists():
35
+ raise FileNotFoundError(f"Workspace config not found: {config_path}")
36
+
37
+ env_path = workspace_path / ".env"
38
+ env: dict[str, str] = {}
39
+ if env_path.exists():
40
+ env = {key: value for key, value in dotenv_values(env_path).items() if value is not None}
41
+
42
+ raw_config = yaml.safe_load(config_path.read_text()) or {}
43
+ return WorkspaceRuntime(
44
+ path=workspace_path,
45
+ config=WorkspaceConfig.model_validate(raw_config),
46
+ env=env,
47
+ )
@@ -0,0 +1 @@
1
+ """Core WorkForge behavior."""
@@ -0,0 +1,97 @@
1
+ from collections.abc import Iterable
2
+
3
+ from workforge.config import WorkspaceConfig
4
+ from workforge.models import Priority, Requirement, WorkTask
5
+
6
+
7
+ META_PREFIXES = {
8
+ "source": "source",
9
+ "priority": "priority",
10
+ "labels": "labels",
11
+ "namespace": "namespace",
12
+ "milestone": "milestone",
13
+ }
14
+
15
+
16
+ def parse_markdown_requirements(content: str, config: WorkspaceConfig) -> list[Requirement]:
17
+ sections = _split_h2_sections(content)
18
+ return [_parse_section(title, lines, config) for title, lines in sections]
19
+
20
+
21
+ def _split_h2_sections(content: str) -> list[tuple[str, list[str]]]:
22
+ sections: list[tuple[str, list[str]]] = []
23
+ current_title: str | None = None
24
+ current_lines: list[str] = []
25
+
26
+ for line in content.splitlines():
27
+ if line.startswith("## "):
28
+ if current_title:
29
+ sections.append((current_title, current_lines))
30
+ current_title = line.removeprefix("## ").strip()
31
+ current_lines = []
32
+ elif current_title:
33
+ current_lines.append(line)
34
+
35
+ if current_title:
36
+ sections.append((current_title, current_lines))
37
+
38
+ return sections
39
+
40
+
41
+ def _parse_section(title: str, lines: Iterable[str], config: WorkspaceConfig) -> Requirement:
42
+ metadata: dict[str, str] = {}
43
+ description_lines: list[str] = []
44
+ tasks: list[WorkTask] = []
45
+
46
+ for line in lines:
47
+ stripped = line.strip()
48
+ if not stripped:
49
+ continue
50
+
51
+ key, value = _parse_metadata_line(stripped)
52
+ if key:
53
+ metadata[key] = value
54
+ continue
55
+
56
+ if stripped.startswith("- "):
57
+ tasks.append(WorkTask(title=stripped.removeprefix("- ").strip()))
58
+ continue
59
+
60
+ description_lines.append(stripped)
61
+
62
+ labels = _split_csv(metadata.get("labels", ""))
63
+ priority = metadata.get("priority", "medium")
64
+
65
+ return Requirement(
66
+ title=title,
67
+ description="\n".join(description_lines),
68
+ source=metadata.get("source", config.defaults.source),
69
+ namespace=metadata.get("namespace", config.defaults.namespace),
70
+ priority=_normalize_priority(priority),
71
+ milestone=metadata.get("milestone"),
72
+ labels=labels,
73
+ tasks=tasks,
74
+ )
75
+
76
+
77
+ def _parse_metadata_line(line: str) -> tuple[str | None, str]:
78
+ if ":" not in line:
79
+ return None, ""
80
+
81
+ raw_key, raw_value = line.split(":", 1)
82
+ key = raw_key.strip().lower()
83
+ if key not in META_PREFIXES:
84
+ return None, ""
85
+
86
+ return META_PREFIXES[key], raw_value.strip()
87
+
88
+
89
+ def _split_csv(value: str) -> list[str]:
90
+ return [part.strip() for part in value.split(",") if part.strip()]
91
+
92
+
93
+ def _normalize_priority(value: str) -> Priority:
94
+ normalized = value.strip().lower()
95
+ if normalized in {"low", "medium", "high", "urgent"}:
96
+ return normalized # type: ignore[return-value]
97
+ return "medium"
workforge/models.py ADDED
@@ -0,0 +1,54 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ Priority = Literal["low", "medium", "high", "urgent"]
7
+
8
+
9
+ class WorkTask(BaseModel):
10
+ title: str
11
+ done: bool = False
12
+
13
+
14
+ class Requirement(BaseModel):
15
+ title: str
16
+ description: str = ""
17
+ source: str = "manual"
18
+ namespace: str = "default"
19
+ priority: Priority = "medium"
20
+ milestone: str | None = None
21
+ labels: list[str] = Field(default_factory=list)
22
+ tasks: list[WorkTask] = Field(default_factory=list)
23
+
24
+
25
+ class CreatedItem(BaseModel):
26
+ provider: str
27
+ id: str
28
+ url: str | None = None
29
+ title: str
30
+
31
+
32
+ class TaskStatus(BaseModel):
33
+ id: str | None = None
34
+ title: str
35
+ done: bool = False
36
+
37
+
38
+ class ItemStatus(BaseModel):
39
+ provider: str
40
+ id: str
41
+ url: str | None = None
42
+ title: str
43
+ closed: bool = False
44
+ tasks: list[TaskStatus] = Field(default_factory=list)
45
+
46
+ @property
47
+ def completed_tasks(self) -> int:
48
+ return sum(1 for task in self.tasks if task.done)
49
+
50
+
51
+ class ProviderCheck(BaseModel):
52
+ provider: str
53
+ ok: bool
54
+ message: str
@@ -0,0 +1 @@
1
+ """Planning provider adapters."""
@@ -0,0 +1,48 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from workforge.models import CreatedItem, ItemStatus, ProviderCheck, Requirement
4
+
5
+
6
+ class PlanningProvider(ABC):
7
+ name: str
8
+
9
+ @abstractmethod
10
+ async def check(self) -> ProviderCheck:
11
+ raise NotImplementedError
12
+
13
+ @abstractmethod
14
+ async def create_requirement(self, requirement: Requirement) -> CreatedItem:
15
+ raise NotImplementedError
16
+
17
+ @abstractmethod
18
+ async def update_requirement_tasks(self, item: CreatedItem, requirement: Requirement) -> ItemStatus:
19
+ raise NotImplementedError
20
+
21
+ @abstractmethod
22
+ async def get_item_status(self, item: CreatedItem) -> ItemStatus:
23
+ raise NotImplementedError
24
+
25
+ @abstractmethod
26
+ async def complete_task(self, item: CreatedItem, task_ref: str) -> ItemStatus:
27
+ raise NotImplementedError
28
+
29
+ @abstractmethod
30
+ async def comment_item(self, item: CreatedItem, text: str) -> ItemStatus:
31
+ raise NotImplementedError
32
+
33
+ @abstractmethod
34
+ async def move_item(self, item: CreatedItem, status_ref: str) -> ItemStatus:
35
+ raise NotImplementedError
36
+
37
+ @abstractmethod
38
+ async def claim_item(self, item: CreatedItem, assignee_ref: str = "@me") -> ItemStatus:
39
+ raise NotImplementedError
40
+
41
+ @abstractmethod
42
+ async def discover_items(
43
+ self,
44
+ label_ref: str | None = None,
45
+ assignee_ref: str | None = None,
46
+ status_ref: str | None = None,
47
+ ) -> list[CreatedItem]:
48
+ raise NotImplementedError