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/cli.py
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import unicodedata
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from workforge.config import WorkspaceRuntime, load_workspace
|
|
12
|
+
from workforge.core.parser import parse_markdown_requirements
|
|
13
|
+
from workforge.models import CreatedItem, ItemStatus, Requirement
|
|
14
|
+
from workforge.providers.registry import build_provider
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True)
|
|
17
|
+
providers_app = typer.Typer(no_args_is_help=True)
|
|
18
|
+
app.add_typer(providers_app, name="providers")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def init(
|
|
23
|
+
workspace: Path = typer.Argument(Path(".workforge"), help="Workspace directory to create."),
|
|
24
|
+
name: str | None = typer.Option(None, "--name", "-n", help="Workspace name. Defaults to the directory name."),
|
|
25
|
+
provider: str = typer.Option("trello", "--provider", "-p", help="Default planning provider."),
|
|
26
|
+
namespace: str | None = typer.Option(None, "--namespace", help="Default requirement namespace."),
|
|
27
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing WorkForge scaffold files."),
|
|
28
|
+
print_gitignore: bool = typer.Option(
|
|
29
|
+
True,
|
|
30
|
+
"--print-gitignore/--no-print-gitignore",
|
|
31
|
+
help="Print recommended .gitignore entries for project-local workspaces.",
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
created = _init_workspace(workspace, name, provider, namespace, force)
|
|
35
|
+
for path in created:
|
|
36
|
+
typer.echo(f"Created {path}")
|
|
37
|
+
|
|
38
|
+
if print_gitignore:
|
|
39
|
+
typer.echo("")
|
|
40
|
+
typer.echo("Recommended .gitignore entries:")
|
|
41
|
+
typer.echo(_project_workspace_gitignore_block(workspace))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def preview(
|
|
46
|
+
input_file: Path,
|
|
47
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
48
|
+
save: bool = typer.Option(False, "--save", help="Save preview output under workspace output/<input-name>/preview.json."),
|
|
49
|
+
) -> None:
|
|
50
|
+
runtime = load_workspace(workspace)
|
|
51
|
+
requirements = parse_markdown_requirements(input_file.read_text(), runtime.config)
|
|
52
|
+
output = [item.model_dump() for item in requirements]
|
|
53
|
+
_echo_json(output)
|
|
54
|
+
if save:
|
|
55
|
+
_save_output(runtime.path, input_file, "preview.json", output)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def create(
|
|
60
|
+
input_file: Path,
|
|
61
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
62
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
63
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
64
|
+
execute: bool = typer.Option(False, "--execute", help="Create external provider items even when workspace dry_run is true."),
|
|
65
|
+
save: bool = typer.Option(False, "--save", help="Save output under workspace output/<input-name>/."),
|
|
66
|
+
) -> None:
|
|
67
|
+
asyncio.run(_create(input_file, workspace, provider, dry_run, execute, save))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def update(
|
|
72
|
+
input_file: Path,
|
|
73
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
74
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
75
|
+
execute: bool = typer.Option(False, "--execute", help="Update task lists in the external provider."),
|
|
76
|
+
save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after updating."),
|
|
77
|
+
) -> None:
|
|
78
|
+
asyncio.run(_update(input_file, workspace, provider, execute, save))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command()
|
|
82
|
+
def discover(
|
|
83
|
+
input_file: Path | None = typer.Argument(
|
|
84
|
+
None,
|
|
85
|
+
help="Optional input file used to choose output/<input-name>/items.json.",
|
|
86
|
+
),
|
|
87
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
88
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
89
|
+
label: str | None = typer.Option(None, "--label", "-l", help="Provider label name or ID used to filter items."),
|
|
90
|
+
assignee: str | None = typer.Option(None, "--assignee", "-a", help="Provider username or @me used to filter items."),
|
|
91
|
+
status: str | None = typer.Option(None, "--status", "-s", help="Provider status name or configured alias used to filter items."),
|
|
92
|
+
output_name: str = typer.Option(
|
|
93
|
+
"discovered",
|
|
94
|
+
"--output-name",
|
|
95
|
+
help="Output directory name when INPUT_FILE is omitted.",
|
|
96
|
+
),
|
|
97
|
+
save: bool = typer.Option(False, "--save", help="Save discovered items as items.json."),
|
|
98
|
+
) -> None:
|
|
99
|
+
asyncio.run(_discover(input_file, workspace, provider, label, assignee, status, output_name, save))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def status(
|
|
104
|
+
input_file: Path,
|
|
105
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
106
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
107
|
+
save: bool = typer.Option(False, "--save", help="Save status output under workspace output/<input-name>/status.json."),
|
|
108
|
+
) -> None:
|
|
109
|
+
asyncio.run(_status(input_file, workspace, provider, save))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command("agent-context")
|
|
113
|
+
def agent_context(
|
|
114
|
+
input_file: Path,
|
|
115
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
116
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
117
|
+
save: bool = typer.Option(False, "--save", help="Save agent context under workspace output/<input-name>/agent-context.md."),
|
|
118
|
+
) -> None:
|
|
119
|
+
asyncio.run(_agent_context(input_file, workspace, provider, save))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("item-context")
|
|
123
|
+
def item_context(
|
|
124
|
+
input_file: Path,
|
|
125
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
126
|
+
item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
|
|
127
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
128
|
+
save: bool = typer.Option(False, "--save", help="Save focused item context under workspace output/<input-name>/items/."),
|
|
129
|
+
) -> None:
|
|
130
|
+
asyncio.run(_item_context(input_file, workspace, item, provider, save))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command("complete-task")
|
|
134
|
+
def complete_task(
|
|
135
|
+
input_file: Path,
|
|
136
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
137
|
+
item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
|
|
138
|
+
task: str = typer.Option(..., "--task", "-t", help="Task title, task ID, or unique title substring."),
|
|
139
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
140
|
+
save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after completion."),
|
|
141
|
+
) -> None:
|
|
142
|
+
asyncio.run(_complete_task(input_file, workspace, item, task, provider, save))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command("comment-item")
|
|
146
|
+
def comment_item(
|
|
147
|
+
input_file: Path,
|
|
148
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
149
|
+
item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
|
|
150
|
+
text: str = typer.Option(..., "--text", "-t", help="Comment text to add to the item."),
|
|
151
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
152
|
+
save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after commenting."),
|
|
153
|
+
) -> None:
|
|
154
|
+
asyncio.run(_comment_item(input_file, workspace, item, text, provider, save))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command("move-item")
|
|
158
|
+
def move_item(
|
|
159
|
+
input_file: Path,
|
|
160
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
161
|
+
item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
|
|
162
|
+
status: str = typer.Option(..., "--status", "-s", help="Destination status name or configured alias."),
|
|
163
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
164
|
+
save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after moving."),
|
|
165
|
+
) -> None:
|
|
166
|
+
asyncio.run(_move_item(input_file, workspace, item, status, provider, save))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command("claim-item")
|
|
170
|
+
def claim_item(
|
|
171
|
+
input_file: Path,
|
|
172
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
173
|
+
item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
|
|
174
|
+
assignee: str = typer.Option("@me", "--assignee", "-a", help="Provider username or @me."),
|
|
175
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
176
|
+
save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after assignment."),
|
|
177
|
+
) -> None:
|
|
178
|
+
asyncio.run(_claim_item(input_file, workspace, item, assignee, provider, save))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@providers_app.command("test")
|
|
182
|
+
def test_provider(
|
|
183
|
+
workspace: Path = typer.Option(..., "--workspace", "-w"),
|
|
184
|
+
provider: str | None = typer.Option(None, "--provider", "-p"),
|
|
185
|
+
) -> None:
|
|
186
|
+
asyncio.run(_test_provider(workspace, provider))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def _create(
|
|
190
|
+
input_file: Path,
|
|
191
|
+
workspace: Path,
|
|
192
|
+
provider_name: str | None,
|
|
193
|
+
dry_run: bool,
|
|
194
|
+
execute: bool,
|
|
195
|
+
save: bool,
|
|
196
|
+
) -> None:
|
|
197
|
+
runtime = load_workspace(workspace)
|
|
198
|
+
config = runtime.config
|
|
199
|
+
requirements = parse_markdown_requirements(input_file.read_text(), config)
|
|
200
|
+
|
|
201
|
+
if dry_run or (config.defaults.dry_run and not execute):
|
|
202
|
+
output = [item.model_dump() for item in requirements]
|
|
203
|
+
_echo_json(output)
|
|
204
|
+
if save:
|
|
205
|
+
_save_output(runtime.path, input_file, "preview.json", output)
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
selected_provider = provider_name or config.default_provider
|
|
209
|
+
provider_config = config.providers.get(selected_provider, {})
|
|
210
|
+
provider = build_provider(selected_provider, provider_config, runtime.env)
|
|
211
|
+
|
|
212
|
+
created = []
|
|
213
|
+
for requirement in requirements:
|
|
214
|
+
created.append((await provider.create_requirement(requirement)).model_dump())
|
|
215
|
+
|
|
216
|
+
_echo_json(created)
|
|
217
|
+
if save:
|
|
218
|
+
_save_output(runtime.path, input_file, "items.json", created)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _update(
|
|
222
|
+
input_file: Path,
|
|
223
|
+
workspace: Path,
|
|
224
|
+
provider_name: str | None,
|
|
225
|
+
execute: bool,
|
|
226
|
+
save: bool,
|
|
227
|
+
) -> None:
|
|
228
|
+
runtime = load_workspace(workspace)
|
|
229
|
+
selected_provider = provider_name or runtime.config.default_provider
|
|
230
|
+
requirements = parse_markdown_requirements(input_file.read_text(), runtime.config)
|
|
231
|
+
items = _load_created_items(runtime.path, input_file, selected_provider)
|
|
232
|
+
matches = [(_find_created_item_by_title(items, requirement.title), requirement) for requirement in requirements]
|
|
233
|
+
|
|
234
|
+
if not execute:
|
|
235
|
+
_echo_json([_task_update_preview(item, requirement) for item, requirement in matches])
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
provider_config = runtime.config.providers.get(selected_provider, {})
|
|
239
|
+
provider = build_provider(selected_provider, provider_config, runtime.env)
|
|
240
|
+
statuses = [await provider.update_requirement_tasks(item, requirement) for item, requirement in matches]
|
|
241
|
+
_echo_json([status.model_dump() for status in statuses])
|
|
242
|
+
if save:
|
|
243
|
+
await _refresh_saved_context(runtime, input_file, provider_name)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _task_update_preview(item: CreatedItem, requirement: Requirement) -> dict[str, Any]:
|
|
247
|
+
return {
|
|
248
|
+
"provider": item.provider,
|
|
249
|
+
"id": item.id,
|
|
250
|
+
"title": requirement.title,
|
|
251
|
+
"tasks": [task.model_dump() for task in requirement.tasks],
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def _test_provider(workspace: Path, provider_name: str | None) -> None:
|
|
256
|
+
runtime = load_workspace(workspace)
|
|
257
|
+
config = runtime.config
|
|
258
|
+
selected_provider = provider_name or config.default_provider
|
|
259
|
+
provider_config = config.providers.get(selected_provider, {})
|
|
260
|
+
provider = build_provider(selected_provider, provider_config, runtime.env)
|
|
261
|
+
result = await provider.check()
|
|
262
|
+
typer.echo(result.model_dump_json(indent=2))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def _discover(
|
|
266
|
+
input_file: Path | None,
|
|
267
|
+
workspace: Path,
|
|
268
|
+
provider_name: str | None,
|
|
269
|
+
label: str | None,
|
|
270
|
+
assignee: str | None,
|
|
271
|
+
status: str | None,
|
|
272
|
+
output_name: str,
|
|
273
|
+
save: bool,
|
|
274
|
+
) -> None:
|
|
275
|
+
runtime = load_workspace(workspace)
|
|
276
|
+
selected_provider = provider_name or runtime.config.default_provider
|
|
277
|
+
provider_config = runtime.config.providers.get(selected_provider, {})
|
|
278
|
+
provider = build_provider(selected_provider, provider_config, runtime.env)
|
|
279
|
+
discovered = [item.model_dump() for item in await provider.discover_items(label, assignee, status)]
|
|
280
|
+
_echo_json(discovered)
|
|
281
|
+
|
|
282
|
+
if save:
|
|
283
|
+
_save_output(runtime.path, _output_ref_for(input_file, output_name), "items.json", discovered)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def _status(input_file: Path, workspace: Path, provider_name: str | None, save: bool) -> None:
|
|
287
|
+
runtime = load_workspace(workspace)
|
|
288
|
+
statuses = await _load_item_statuses(runtime, input_file, provider_name)
|
|
289
|
+
output = [status.model_dump() for status in statuses]
|
|
290
|
+
_echo_json(output)
|
|
291
|
+
if save:
|
|
292
|
+
_save_output(runtime.path, input_file, "status.json", output)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _agent_context(input_file: Path, workspace: Path, provider_name: str | None, save: bool) -> None:
|
|
296
|
+
runtime = load_workspace(workspace)
|
|
297
|
+
statuses = await _load_item_statuses(runtime, input_file, provider_name)
|
|
298
|
+
output = _build_agent_context(statuses)
|
|
299
|
+
typer.echo(output)
|
|
300
|
+
if save:
|
|
301
|
+
_save_text_output(runtime.path, input_file, "agent-context.md", output)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _item_context(
|
|
305
|
+
input_file: Path,
|
|
306
|
+
workspace: Path,
|
|
307
|
+
item_ref: str,
|
|
308
|
+
provider_name: str | None,
|
|
309
|
+
save: bool,
|
|
310
|
+
) -> None:
|
|
311
|
+
runtime = load_workspace(workspace)
|
|
312
|
+
statuses = await _load_item_statuses(runtime, input_file, provider_name)
|
|
313
|
+
status = _find_item_status(statuses, item_ref)
|
|
314
|
+
output = _build_item_context(status)
|
|
315
|
+
typer.echo(output)
|
|
316
|
+
if save:
|
|
317
|
+
output_path = _item_context_path(runtime.path, input_file, status)
|
|
318
|
+
_save_text_path(output_path, output)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def _complete_task(
|
|
322
|
+
input_file: Path,
|
|
323
|
+
workspace: Path,
|
|
324
|
+
item_ref: str,
|
|
325
|
+
task_ref: str,
|
|
326
|
+
provider_name: str | None,
|
|
327
|
+
save: bool,
|
|
328
|
+
) -> None:
|
|
329
|
+
runtime = load_workspace(workspace)
|
|
330
|
+
selected_provider = provider_name or runtime.config.default_provider
|
|
331
|
+
provider_config = runtime.config.providers.get(selected_provider, {})
|
|
332
|
+
provider = build_provider(selected_provider, provider_config, runtime.env)
|
|
333
|
+
created_items = _load_created_items(runtime.path, input_file, selected_provider)
|
|
334
|
+
item = _find_created_item(created_items, item_ref)
|
|
335
|
+
updated_status = await provider.complete_task(item, task_ref)
|
|
336
|
+
|
|
337
|
+
_echo_json(updated_status.model_dump())
|
|
338
|
+
|
|
339
|
+
if save:
|
|
340
|
+
statuses = await _load_item_statuses(runtime, input_file, provider_name)
|
|
341
|
+
status_output = [status.model_dump() for status in statuses]
|
|
342
|
+
_save_output(runtime.path, input_file, "status.json", status_output)
|
|
343
|
+
_save_text_output(runtime.path, input_file, "agent-context.md", _build_agent_context(statuses))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
async def _comment_item(
|
|
347
|
+
input_file: Path,
|
|
348
|
+
workspace: Path,
|
|
349
|
+
item_ref: str,
|
|
350
|
+
text: str,
|
|
351
|
+
provider_name: str | None,
|
|
352
|
+
save: bool,
|
|
353
|
+
) -> None:
|
|
354
|
+
runtime = load_workspace(workspace)
|
|
355
|
+
selected_provider = provider_name or runtime.config.default_provider
|
|
356
|
+
provider_config = runtime.config.providers.get(selected_provider, {})
|
|
357
|
+
provider = build_provider(selected_provider, provider_config, runtime.env)
|
|
358
|
+
created_items = _load_created_items(runtime.path, input_file, selected_provider)
|
|
359
|
+
item = _find_created_item(created_items, item_ref)
|
|
360
|
+
updated_status = await provider.comment_item(item, text)
|
|
361
|
+
|
|
362
|
+
_echo_json(updated_status.model_dump())
|
|
363
|
+
|
|
364
|
+
if save:
|
|
365
|
+
await _refresh_saved_context(runtime, input_file, provider_name)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
async def _move_item(
|
|
369
|
+
input_file: Path,
|
|
370
|
+
workspace: Path,
|
|
371
|
+
item_ref: str,
|
|
372
|
+
status_ref: str,
|
|
373
|
+
provider_name: str | None,
|
|
374
|
+
save: bool,
|
|
375
|
+
) -> None:
|
|
376
|
+
runtime = load_workspace(workspace)
|
|
377
|
+
selected_provider = provider_name or runtime.config.default_provider
|
|
378
|
+
provider_config = runtime.config.providers.get(selected_provider, {})
|
|
379
|
+
provider = build_provider(selected_provider, provider_config, runtime.env)
|
|
380
|
+
created_items = _load_created_items(runtime.path, input_file, selected_provider)
|
|
381
|
+
item = _find_created_item(created_items, item_ref)
|
|
382
|
+
updated_status = await provider.move_item(item, status_ref)
|
|
383
|
+
|
|
384
|
+
_echo_json(updated_status.model_dump())
|
|
385
|
+
|
|
386
|
+
if save:
|
|
387
|
+
await _refresh_saved_context(runtime, input_file, provider_name)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
async def _claim_item(
|
|
391
|
+
input_file: Path,
|
|
392
|
+
workspace: Path,
|
|
393
|
+
item_ref: str,
|
|
394
|
+
assignee_ref: str,
|
|
395
|
+
provider_name: str | None,
|
|
396
|
+
save: bool,
|
|
397
|
+
) -> None:
|
|
398
|
+
runtime = load_workspace(workspace)
|
|
399
|
+
selected_provider = provider_name or runtime.config.default_provider
|
|
400
|
+
provider_config = runtime.config.providers.get(selected_provider, {})
|
|
401
|
+
provider = build_provider(selected_provider, provider_config, runtime.env)
|
|
402
|
+
item = _find_created_item(_load_created_items(runtime.path, input_file, selected_provider), item_ref)
|
|
403
|
+
updated_status = await provider.claim_item(item, assignee_ref)
|
|
404
|
+
|
|
405
|
+
_echo_json(updated_status.model_dump())
|
|
406
|
+
if save:
|
|
407
|
+
await _refresh_saved_context(runtime, input_file, provider_name)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
async def _refresh_saved_context(
|
|
411
|
+
runtime: WorkspaceRuntime,
|
|
412
|
+
input_file: Path,
|
|
413
|
+
provider_name: str | None,
|
|
414
|
+
) -> None:
|
|
415
|
+
statuses = await _load_item_statuses(runtime, input_file, provider_name)
|
|
416
|
+
status_output = [status.model_dump() for status in statuses]
|
|
417
|
+
_save_output(runtime.path, input_file, "status.json", status_output)
|
|
418
|
+
_save_text_output(runtime.path, input_file, "agent-context.md", _build_agent_context(statuses))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def _load_item_statuses(
|
|
422
|
+
runtime: WorkspaceRuntime,
|
|
423
|
+
input_file: Path,
|
|
424
|
+
provider_name: str | None,
|
|
425
|
+
) -> list[ItemStatus]:
|
|
426
|
+
selected_provider = provider_name or runtime.config.default_provider
|
|
427
|
+
provider_config = runtime.config.providers.get(selected_provider, {})
|
|
428
|
+
provider = build_provider(selected_provider, provider_config, runtime.env)
|
|
429
|
+
created_items = _load_created_items(runtime.path, input_file, selected_provider)
|
|
430
|
+
return [await provider.get_item_status(item) for item in created_items]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _load_created_items(workspace_path: Path, input_file: Path, provider_name: str) -> list[CreatedItem]:
|
|
434
|
+
items_path = _output_dir_for(workspace_path, input_file) / "items.json"
|
|
435
|
+
if not items_path.exists():
|
|
436
|
+
raise FileNotFoundError(f"Created items output not found: {items_path}")
|
|
437
|
+
|
|
438
|
+
raw_items = json.loads(items_path.read_text())
|
|
439
|
+
return [
|
|
440
|
+
CreatedItem.model_validate(item)
|
|
441
|
+
for item in raw_items
|
|
442
|
+
if item.get("provider") == provider_name
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _find_created_item(items: list[CreatedItem], item_ref: str) -> CreatedItem:
|
|
447
|
+
exact_matches = [
|
|
448
|
+
item
|
|
449
|
+
for item in items
|
|
450
|
+
if item.id == item_ref or item.title.casefold() == item_ref.casefold()
|
|
451
|
+
]
|
|
452
|
+
if len(exact_matches) == 1:
|
|
453
|
+
return exact_matches[0]
|
|
454
|
+
if len(exact_matches) > 1:
|
|
455
|
+
raise ValueError(f"Multiple items matched exactly: {item_ref}")
|
|
456
|
+
|
|
457
|
+
partial_matches = [
|
|
458
|
+
item
|
|
459
|
+
for item in items
|
|
460
|
+
if item_ref.casefold() in item.title.casefold()
|
|
461
|
+
]
|
|
462
|
+
if len(partial_matches) == 1:
|
|
463
|
+
return partial_matches[0]
|
|
464
|
+
if len(partial_matches) > 1:
|
|
465
|
+
titles = ", ".join(item.title for item in partial_matches)
|
|
466
|
+
raise ValueError(f"Multiple items matched '{item_ref}': {titles}")
|
|
467
|
+
|
|
468
|
+
raise ValueError(f"Item not found: {item_ref}")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _find_created_item_by_title(items: list[CreatedItem], title: str) -> CreatedItem:
|
|
472
|
+
matches = [item for item in items if item.title.casefold() == title.casefold()]
|
|
473
|
+
if len(matches) == 1:
|
|
474
|
+
return matches[0]
|
|
475
|
+
if len(matches) > 1:
|
|
476
|
+
raise ValueError(f"Multiple items matched title: {title}")
|
|
477
|
+
raise ValueError(f"Item title not found: {title}")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _find_item_status(statuses: list[ItemStatus], item_ref: str) -> ItemStatus:
|
|
481
|
+
exact_matches = [
|
|
482
|
+
status
|
|
483
|
+
for status in statuses
|
|
484
|
+
if status.id == item_ref or status.title.casefold() == item_ref.casefold()
|
|
485
|
+
]
|
|
486
|
+
if len(exact_matches) == 1:
|
|
487
|
+
return exact_matches[0]
|
|
488
|
+
if len(exact_matches) > 1:
|
|
489
|
+
raise ValueError(f"Multiple items matched exactly: {item_ref}")
|
|
490
|
+
|
|
491
|
+
partial_matches = [
|
|
492
|
+
status
|
|
493
|
+
for status in statuses
|
|
494
|
+
if item_ref.casefold() in status.title.casefold()
|
|
495
|
+
]
|
|
496
|
+
if len(partial_matches) == 1:
|
|
497
|
+
return partial_matches[0]
|
|
498
|
+
if len(partial_matches) > 1:
|
|
499
|
+
titles = ", ".join(status.title for status in partial_matches)
|
|
500
|
+
raise ValueError(f"Multiple items matched '{item_ref}': {titles}")
|
|
501
|
+
|
|
502
|
+
raise ValueError(f"Item not found: {item_ref}")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _build_agent_context(statuses: list[ItemStatus]) -> str:
|
|
506
|
+
lines = ["# WorkForge Agent Context", ""]
|
|
507
|
+
|
|
508
|
+
for status in statuses:
|
|
509
|
+
total_tasks = len(status.tasks)
|
|
510
|
+
pending_tasks = [task for task in status.tasks if not task.done]
|
|
511
|
+
completed_tasks = [task for task in status.tasks if task.done]
|
|
512
|
+
lines.extend(
|
|
513
|
+
[
|
|
514
|
+
f"## {status.title}",
|
|
515
|
+
"",
|
|
516
|
+
f"Provider: {status.provider}",
|
|
517
|
+
f"Item ID: {status.id}",
|
|
518
|
+
f"URL: {status.url or ''}",
|
|
519
|
+
f"Item closed: {status.closed}",
|
|
520
|
+
f"Progress: {len(completed_tasks)}/{total_tasks} tasks complete",
|
|
521
|
+
"",
|
|
522
|
+
"### Pending Tasks",
|
|
523
|
+
"",
|
|
524
|
+
]
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
if pending_tasks:
|
|
528
|
+
lines.extend(f"- {task.title}" for task in pending_tasks)
|
|
529
|
+
else:
|
|
530
|
+
lines.append("- None")
|
|
531
|
+
|
|
532
|
+
lines.extend(["", "### Completed Tasks", ""])
|
|
533
|
+
if completed_tasks:
|
|
534
|
+
lines.extend(f"- {task.title}" for task in completed_tasks)
|
|
535
|
+
else:
|
|
536
|
+
lines.append("- None")
|
|
537
|
+
lines.append("")
|
|
538
|
+
|
|
539
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _build_item_context(status: ItemStatus) -> str:
|
|
543
|
+
total_tasks = len(status.tasks)
|
|
544
|
+
pending_tasks = [task for task in status.tasks if not task.done]
|
|
545
|
+
completed_tasks = [task for task in status.tasks if task.done]
|
|
546
|
+
lines = [
|
|
547
|
+
f"# {status.title}",
|
|
548
|
+
"",
|
|
549
|
+
f"Provider: {status.provider}",
|
|
550
|
+
f"Item ID: {status.id}",
|
|
551
|
+
f"URL: {status.url or ''}",
|
|
552
|
+
f"Item closed: {status.closed}",
|
|
553
|
+
f"Progress: {len(completed_tasks)}/{total_tasks} tasks complete",
|
|
554
|
+
"",
|
|
555
|
+
"## Implementation Focus",
|
|
556
|
+
"",
|
|
557
|
+
"Work only on this item unless the implementation requires a small supporting change.",
|
|
558
|
+
"Prefer completing one pending task at a time, then run the relevant checks before marking it done.",
|
|
559
|
+
"",
|
|
560
|
+
"## Pending Tasks",
|
|
561
|
+
"",
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
if pending_tasks:
|
|
565
|
+
lines.extend(_format_task_line(task) for task in pending_tasks)
|
|
566
|
+
else:
|
|
567
|
+
lines.append("- None")
|
|
568
|
+
|
|
569
|
+
lines.extend(["", "## Completed Tasks", ""])
|
|
570
|
+
if completed_tasks:
|
|
571
|
+
lines.extend(_format_task_line(task) for task in completed_tasks)
|
|
572
|
+
else:
|
|
573
|
+
lines.append("- None")
|
|
574
|
+
|
|
575
|
+
lines.extend(
|
|
576
|
+
[
|
|
577
|
+
"",
|
|
578
|
+
"## Completion Command",
|
|
579
|
+
"",
|
|
580
|
+
"After implementing and verifying a task, mark it complete with:",
|
|
581
|
+
"",
|
|
582
|
+
"```bash",
|
|
583
|
+
"workforge complete-task <input-file> --workspace <workspace> "
|
|
584
|
+
f"--item \"{status.id}\" --task \"<task-id-or-title>\"",
|
|
585
|
+
"```",
|
|
586
|
+
]
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _format_task_line(task: Any) -> str:
|
|
593
|
+
suffix = f" (`{task.id}`)" if task.id else ""
|
|
594
|
+
return f"- {task.title}{suffix}"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _output_dir_for(workspace_path: Path, input_file: Path) -> Path:
|
|
598
|
+
return workspace_path / "output" / input_file.stem
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _item_context_path(workspace_path: Path, input_file: Path, status: ItemStatus) -> Path:
|
|
602
|
+
return _output_dir_for(workspace_path, input_file) / "items" / f"{_slugify(status.title)}.md"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _slugify(value: str) -> str:
|
|
606
|
+
normalized = unicodedata.normalize("NFKD", value.casefold())
|
|
607
|
+
normalized = "".join(character for character in normalized if not unicodedata.combining(character))
|
|
608
|
+
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
|
609
|
+
normalized = normalized.strip("-")
|
|
610
|
+
return normalized or "item"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _output_ref_for(input_file: Path | None, output_name: str) -> Path:
|
|
614
|
+
return input_file or Path(output_name)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _init_workspace(
|
|
618
|
+
workspace_path: Path,
|
|
619
|
+
name: str | None,
|
|
620
|
+
provider: str,
|
|
621
|
+
namespace: str | None,
|
|
622
|
+
force: bool,
|
|
623
|
+
) -> list[Path]:
|
|
624
|
+
workspace_name = name or workspace_path.name.removeprefix(".") or "workspace"
|
|
625
|
+
default_namespace = namespace or workspace_name
|
|
626
|
+
created: list[Path] = []
|
|
627
|
+
|
|
628
|
+
for directory in [workspace_path, workspace_path / "inbox", workspace_path / "output"]:
|
|
629
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
630
|
+
|
|
631
|
+
files = {
|
|
632
|
+
workspace_path / "workforge.yaml": _workspace_config_template(workspace_name, provider, default_namespace),
|
|
633
|
+
workspace_path / ".env.example": _env_example_template(provider),
|
|
634
|
+
workspace_path / "output" / ".gitkeep": "",
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
for path, content in files.items():
|
|
638
|
+
if path.exists() and not force:
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
path.write_text(content)
|
|
642
|
+
created.append(path)
|
|
643
|
+
|
|
644
|
+
return created
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _workspace_config_template(name: str, provider: str, namespace: str) -> str:
|
|
648
|
+
provider_config: dict[str, Any]
|
|
649
|
+
if provider == "trello":
|
|
650
|
+
provider_config = {
|
|
651
|
+
"list_id": "replace-with-trello-list-id",
|
|
652
|
+
"lists": {
|
|
653
|
+
"todo": "replace-with-trello-todo-list-id",
|
|
654
|
+
"doing": "replace-with-trello-doing-list-id",
|
|
655
|
+
"done": "replace-with-trello-done-list-id",
|
|
656
|
+
},
|
|
657
|
+
"labels": {},
|
|
658
|
+
}
|
|
659
|
+
elif provider == "github":
|
|
660
|
+
provider_config = {
|
|
661
|
+
"owner": "replace-with-github-owner",
|
|
662
|
+
"repository": "replace-with-repository",
|
|
663
|
+
"project_number": 1,
|
|
664
|
+
"labels": {},
|
|
665
|
+
"milestones": {},
|
|
666
|
+
"status": {
|
|
667
|
+
"field": "Status",
|
|
668
|
+
"values": {
|
|
669
|
+
"todo": "Todo",
|
|
670
|
+
"doing": "In Progress",
|
|
671
|
+
"done": "Done",
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
else:
|
|
676
|
+
provider_config = {}
|
|
677
|
+
|
|
678
|
+
payload = {
|
|
679
|
+
"name": name,
|
|
680
|
+
"default_provider": provider,
|
|
681
|
+
"providers": {
|
|
682
|
+
provider: provider_config,
|
|
683
|
+
},
|
|
684
|
+
"defaults": {
|
|
685
|
+
"source": "manual",
|
|
686
|
+
"namespace": namespace,
|
|
687
|
+
"dry_run": True,
|
|
688
|
+
},
|
|
689
|
+
}
|
|
690
|
+
return yaml.safe_dump(payload, sort_keys=False)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _env_example_template(provider: str) -> str:
|
|
694
|
+
if provider == "trello":
|
|
695
|
+
return "TRELLO_API_KEY=\nTRELLO_API_TOKEN=\n"
|
|
696
|
+
if provider == "github":
|
|
697
|
+
return "GITHUB_TOKEN=\n"
|
|
698
|
+
|
|
699
|
+
return ""
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _project_workspace_gitignore_block(workspace_path: Path) -> str:
|
|
703
|
+
workspace_ref = workspace_path.name if workspace_path.is_absolute() else workspace_path.as_posix().rstrip("/")
|
|
704
|
+
return f"{workspace_ref}/.env\n{workspace_ref}/output/\n"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _save_output(workspace_path: Path, input_file: Path, filename: str, payload: Any) -> Path:
|
|
708
|
+
output_dir = _output_dir_for(workspace_path, input_file)
|
|
709
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
710
|
+
output_path = output_dir / filename
|
|
711
|
+
output_path.write_text(json.dumps(payload, indent=2) + "\n")
|
|
712
|
+
typer.echo(f"Saved {output_path}")
|
|
713
|
+
return output_path
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _save_text_output(workspace_path: Path, input_file: Path, filename: str, payload: str) -> Path:
|
|
717
|
+
output_dir = _output_dir_for(workspace_path, input_file)
|
|
718
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
719
|
+
output_path = output_dir / filename
|
|
720
|
+
output_path.write_text(payload)
|
|
721
|
+
typer.echo(f"Saved {output_path}")
|
|
722
|
+
return output_path
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _save_text_path(output_path: Path, payload: str) -> Path:
|
|
726
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
727
|
+
output_path.write_text(payload)
|
|
728
|
+
typer.echo(f"Saved {output_path}")
|
|
729
|
+
return output_path
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _echo_json(payload: Any) -> None:
|
|
733
|
+
typer.echo(json.dumps(payload, indent=2))
|