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/__init__.py +5 -0
- workforge/cli.py +733 -0
- workforge/config.py +47 -0
- workforge/core/__init__.py +1 -0
- workforge/core/parser.py +97 -0
- workforge/models.py +54 -0
- workforge/providers/__init__.py +1 -0
- workforge/providers/base.py +48 -0
- workforge/providers/github.py +606 -0
- workforge/providers/jira.py +509 -0
- workforge/providers/registry.py +17 -0
- workforge/providers/trello.py +469 -0
- workforge-2.4.1.dist-info/METADATA +515 -0
- workforge-2.4.1.dist-info/RECORD +17 -0
- workforge-2.4.1.dist-info/WHEEL +4 -0
- workforge-2.4.1.dist-info/entry_points.txt +2 -0
- workforge-2.4.1.dist-info/licenses/LICENSE +21 -0
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."""
|
workforge/core/parser.py
ADDED
|
@@ -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
|