lemming-cli 0.1.0__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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
lemming/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lemming: an autonomous, iterative task runner for AI agents."""
|
lemming/api/__init__.py
ADDED
lemming/api/auth.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Share-token authentication middleware for remotely shared servers."""
|
|
2
|
+
|
|
3
|
+
import fastapi
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def share_token_middleware(request: fastapi.Request, call_next):
|
|
7
|
+
"""Require a share token for non-local requests when one is configured.
|
|
8
|
+
|
|
9
|
+
Accepts the token via the ``token`` query parameter (persisting it in a
|
|
10
|
+
cookie) or via the ``lemming_share_token`` cookie. Requests from
|
|
11
|
+
localhost bypass the check. Returns 401 when the token is missing or
|
|
12
|
+
invalid.
|
|
13
|
+
"""
|
|
14
|
+
share_token = getattr(request.app.state, "share_token", None)
|
|
15
|
+
if not share_token:
|
|
16
|
+
return await call_next(request)
|
|
17
|
+
|
|
18
|
+
host = request.headers.get("host", "")
|
|
19
|
+
if host.startswith("127.0.0.1") or host.startswith("localhost"):
|
|
20
|
+
return await call_next(request)
|
|
21
|
+
|
|
22
|
+
token = request.query_params.get("token")
|
|
23
|
+
if token == share_token:
|
|
24
|
+
response = await call_next(request)
|
|
25
|
+
response.set_cookie(
|
|
26
|
+
key="lemming_share_token", value=token, httponly=True
|
|
27
|
+
)
|
|
28
|
+
return response
|
|
29
|
+
|
|
30
|
+
cookie_token = request.cookies.get("lemming_share_token")
|
|
31
|
+
if cookie_token == share_token:
|
|
32
|
+
return await call_next(request)
|
|
33
|
+
|
|
34
|
+
return fastapi.Response("Unauthorized", status_code=401)
|
lemming/api/auth_test.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fastapi.testclient
|
|
2
|
+
|
|
3
|
+
from lemming import api
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_share_token_middleware():
|
|
7
|
+
# Setup test client
|
|
8
|
+
original_token = getattr(api.app.state, "share_token", None)
|
|
9
|
+
try:
|
|
10
|
+
api.app.state.share_token = "secret123"
|
|
11
|
+
# We need a fresh client for each test that modifies app state
|
|
12
|
+
# middleware if it uses the app state, but here TestClient is
|
|
13
|
+
# created with api.app
|
|
14
|
+
client = fastapi.testclient.TestClient(api.app)
|
|
15
|
+
|
|
16
|
+
# Missing token -> 401
|
|
17
|
+
response = client.get("/api/data")
|
|
18
|
+
assert response.status_code == 401
|
|
19
|
+
|
|
20
|
+
# Valid token via query
|
|
21
|
+
response = client.get("/api/data?token=secret123")
|
|
22
|
+
assert response.status_code == 200
|
|
23
|
+
assert "lemming_share_token=secret123" in response.headers.get(
|
|
24
|
+
"set-cookie", ""
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Valid token via cookie
|
|
28
|
+
client.cookies.set("lemming_share_token", "secret123")
|
|
29
|
+
response = client.get("/api/data")
|
|
30
|
+
assert response.status_code == 200
|
|
31
|
+
|
|
32
|
+
# Local bypass via host header
|
|
33
|
+
response = client.get("/api/data", headers={"host": "127.0.0.1:8999"})
|
|
34
|
+
assert response.status_code == 200
|
|
35
|
+
|
|
36
|
+
response = client.get("/api/data", headers={"host": "localhost:8999"})
|
|
37
|
+
assert response.status_code == 200
|
|
38
|
+
finally:
|
|
39
|
+
# Restore
|
|
40
|
+
api.app.state.share_token = original_token
|
lemming/api/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""API routes for runner discovery, project configuration, and loop runs."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import fastapi
|
|
8
|
+
import pydantic
|
|
9
|
+
|
|
10
|
+
from .. import models, tasks
|
|
11
|
+
from . import context
|
|
12
|
+
|
|
13
|
+
router = fastapi.APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/api/runners")
|
|
17
|
+
def get_runners():
|
|
18
|
+
"""List the names of all known task runners."""
|
|
19
|
+
return list(models.KNOWN_RUNNERS)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/api/context")
|
|
23
|
+
def update_context(
|
|
24
|
+
request: fastapi.Request, update: dict, project: str | None = None
|
|
25
|
+
):
|
|
26
|
+
"""Update the shared project context passed to task runners."""
|
|
27
|
+
tasks_file = context.resolve_tasks_file(request.app.state, project)
|
|
28
|
+
tasks.update_context(tasks_file, update.get("context", ""))
|
|
29
|
+
return {"status": "ok"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/api/config")
|
|
33
|
+
def update_config(
|
|
34
|
+
request: fastapi.Request,
|
|
35
|
+
config: tasks.RoadmapConfig,
|
|
36
|
+
project: str | None = None,
|
|
37
|
+
):
|
|
38
|
+
"""Replace the roadmap configuration and return the saved value."""
|
|
39
|
+
tasks_file = context.resolve_tasks_file(request.app.state, project)
|
|
40
|
+
with tasks.lock_tasks(tasks_file):
|
|
41
|
+
data = tasks.load_tasks(tasks_file)
|
|
42
|
+
data.config = config
|
|
43
|
+
tasks.save_tasks(tasks_file, data)
|
|
44
|
+
return data.config
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RunRequest(pydantic.BaseModel):
|
|
48
|
+
"""Request body for starting the orchestrator loop."""
|
|
49
|
+
|
|
50
|
+
env: dict[str, str] | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.post("/api/run")
|
|
54
|
+
def run_loop(
|
|
55
|
+
request: fastapi.Request,
|
|
56
|
+
run_request: RunRequest,
|
|
57
|
+
project: str | None = None,
|
|
58
|
+
):
|
|
59
|
+
"""Start the orchestrator loop as a detached background process."""
|
|
60
|
+
tasks_file = context.resolve_tasks_file(request.app.state, project)
|
|
61
|
+
project_dir = context.resolve_project_dir(request.app.state, project)
|
|
62
|
+
|
|
63
|
+
# Use sys.executable -m lemming.main to ensure we use the same environment
|
|
64
|
+
# and pass the explicit tasks file.
|
|
65
|
+
cmd = [
|
|
66
|
+
sys.executable,
|
|
67
|
+
"-m",
|
|
68
|
+
"lemming.main",
|
|
69
|
+
]
|
|
70
|
+
if getattr(request.app.state, "verbose", False):
|
|
71
|
+
cmd.append("--verbose")
|
|
72
|
+
cmd.extend(
|
|
73
|
+
[
|
|
74
|
+
"--tasks-file",
|
|
75
|
+
str(tasks_file),
|
|
76
|
+
"run",
|
|
77
|
+
]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
env = os.environ.copy()
|
|
81
|
+
if run_request.env:
|
|
82
|
+
env.update(run_request.env)
|
|
83
|
+
|
|
84
|
+
subprocess.Popen(cmd, start_new_session=True, env=env, cwd=project_dir)
|
|
85
|
+
return {"status": "started"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
from lemming import api
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_run_loop(client, test_tasks):
|
|
7
|
+
with patch("subprocess.Popen") as mock_popen:
|
|
8
|
+
response = client.post("/api/run", json={"env": {"KEY": "VALUE"}})
|
|
9
|
+
assert response.status_code == 200
|
|
10
|
+
assert response.json() == {"status": "started"}
|
|
11
|
+
|
|
12
|
+
args = mock_popen.call_args[0][0]
|
|
13
|
+
assert "run" in args
|
|
14
|
+
# Check that we didn't pass redundant flags
|
|
15
|
+
assert "--runner" not in args
|
|
16
|
+
assert "--retries" not in args
|
|
17
|
+
assert "--hook" not in args
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_get_runners(client):
|
|
21
|
+
response = client.get("/api/runners")
|
|
22
|
+
assert response.status_code == 200
|
|
23
|
+
assert response.json() == ["agy", "aider", "claude", "codex"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_project_context_isolation(client, test_tasks):
|
|
27
|
+
"""Context is isolated per project."""
|
|
28
|
+
root = api.app.state.root
|
|
29
|
+
subdir = root / "ctx_test"
|
|
30
|
+
subdir.mkdir(exist_ok=True)
|
|
31
|
+
|
|
32
|
+
# Set context for sub-project
|
|
33
|
+
response = client.post(
|
|
34
|
+
"/api/context",
|
|
35
|
+
json={"context": "Sub-project context"},
|
|
36
|
+
params={"project": "ctx_test"},
|
|
37
|
+
)
|
|
38
|
+
assert response.status_code == 200
|
|
39
|
+
|
|
40
|
+
# Root context should be unchanged
|
|
41
|
+
res = client.get("/api/data")
|
|
42
|
+
assert res.status_code == 200
|
|
43
|
+
assert res.json()["context"] == "Initial context"
|
|
44
|
+
|
|
45
|
+
# Sub-project context should be set
|
|
46
|
+
res = client.get("/api/data", params={"project": "ctx_test"})
|
|
47
|
+
assert res.status_code == 200
|
|
48
|
+
assert res.json()["context"] == "Sub-project context"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_run_loop_with_project(client, test_tasks):
|
|
52
|
+
"""POST /api/run with project param uses the correct tasks file."""
|
|
53
|
+
root = api.app.state.root
|
|
54
|
+
subdir = root / "run_project"
|
|
55
|
+
subdir.mkdir(exist_ok=True)
|
|
56
|
+
(subdir / "tasks.yml").touch()
|
|
57
|
+
|
|
58
|
+
with patch("subprocess.Popen") as mock_popen:
|
|
59
|
+
response = client.post(
|
|
60
|
+
"/api/run",
|
|
61
|
+
json={"runner": "claude"},
|
|
62
|
+
params={"project": "run_project"},
|
|
63
|
+
)
|
|
64
|
+
assert response.status_code == 200
|
|
65
|
+
args = mock_popen.call_args[0][0]
|
|
66
|
+
# The tasks file should be for the sub-project, not the default
|
|
67
|
+
tasks_file_idx = args.index("--tasks-file") + 1
|
|
68
|
+
assert "run_project" in args[tasks_file_idx]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_run_loop_with_project_cwd(test_workspace, client):
|
|
72
|
+
root_dir, subproject_dir = test_workspace
|
|
73
|
+
|
|
74
|
+
with patch("subprocess.Popen") as mock_popen:
|
|
75
|
+
response = client.post(
|
|
76
|
+
"/api/run",
|
|
77
|
+
json={"runner": "echo", "retries": 1},
|
|
78
|
+
params={"project": "my-subproject"},
|
|
79
|
+
)
|
|
80
|
+
assert response.status_code == 200
|
|
81
|
+
|
|
82
|
+
# Verify Popen was called with the correct cwd
|
|
83
|
+
_, kwargs = mock_popen.call_args
|
|
84
|
+
assert str(kwargs["cwd"]) == str(subproject_dir)
|
lemming/api/conftest.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Shared pytest fixtures for the API test suite."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import fastapi.testclient
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from lemming import api, paths, tasks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def client():
|
|
18
|
+
"""A TestClient bound to the lemming FastAPI app."""
|
|
19
|
+
return fastapi.testclient.TestClient(api.app)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def test_tasks():
|
|
24
|
+
"""A temporary tasks file with sample tasks, wired into the app state."""
|
|
25
|
+
# Create a temporary directory and a tasks file
|
|
26
|
+
test_dir = tempfile.mkdtemp()
|
|
27
|
+
test_tasks_file = pathlib.Path(test_dir) / "tasks_test.yml"
|
|
28
|
+
|
|
29
|
+
# Scaffold a valid file
|
|
30
|
+
data = tasks.Roadmap(
|
|
31
|
+
context="Initial context",
|
|
32
|
+
tasks=[
|
|
33
|
+
tasks.Task(
|
|
34
|
+
id="task1",
|
|
35
|
+
description="Completed Task",
|
|
36
|
+
status=tasks.TaskStatus.COMPLETED,
|
|
37
|
+
attempts=1,
|
|
38
|
+
progress=["All good"],
|
|
39
|
+
completed_at=123456789.0,
|
|
40
|
+
),
|
|
41
|
+
tasks.Task(
|
|
42
|
+
id="task2",
|
|
43
|
+
description="Pending Task",
|
|
44
|
+
status=tasks.TaskStatus.PENDING,
|
|
45
|
+
attempts=0,
|
|
46
|
+
progress=[],
|
|
47
|
+
),
|
|
48
|
+
tasks.Task(
|
|
49
|
+
id="task3",
|
|
50
|
+
description="In Progress Task",
|
|
51
|
+
status=tasks.TaskStatus.IN_PROGRESS,
|
|
52
|
+
attempts=1,
|
|
53
|
+
progress=[],
|
|
54
|
+
pid=os.getpid(),
|
|
55
|
+
last_heartbeat=time.time(),
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
tasks.save_tasks(test_tasks_file, data)
|
|
60
|
+
|
|
61
|
+
# Override the TASKS_FILE and root in the api module
|
|
62
|
+
original_tasks_file = api.app.state.tasks_file
|
|
63
|
+
original_root = api.app.state.root
|
|
64
|
+
original_auto_start = api.app.state.disable_auto_start
|
|
65
|
+
api.app.state.tasks_file = test_tasks_file
|
|
66
|
+
api.app.state.root = pathlib.Path(test_dir).resolve()
|
|
67
|
+
api.app.state.disable_auto_start = True
|
|
68
|
+
|
|
69
|
+
yield test_tasks_file
|
|
70
|
+
|
|
71
|
+
# Restore the originals
|
|
72
|
+
api.app.state.tasks_file = original_tasks_file
|
|
73
|
+
api.app.state.root = original_root
|
|
74
|
+
api.app.state.disable_auto_start = original_auto_start
|
|
75
|
+
shutil.rmtree(test_dir)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def git_repo():
|
|
80
|
+
"""A temporary git repo with tracked and gitignored files as the root."""
|
|
81
|
+
# Create a temporary directory and initialize a git repo
|
|
82
|
+
test_dir = tempfile.mkdtemp()
|
|
83
|
+
orig_cwd = os.getcwd()
|
|
84
|
+
original_root = api.app.state.root
|
|
85
|
+
os.chdir(test_dir)
|
|
86
|
+
api.app.state.root = pathlib.Path(test_dir).resolve()
|
|
87
|
+
|
|
88
|
+
# Clear cached git repo check from previous tests
|
|
89
|
+
if hasattr(paths.in_git_repo, "_result"):
|
|
90
|
+
del paths.in_git_repo._result
|
|
91
|
+
|
|
92
|
+
subprocess.run(["git", "init"], check=True)
|
|
93
|
+
subprocess.run(
|
|
94
|
+
["git", "config", "user.email", "you@example.com"], check=True
|
|
95
|
+
)
|
|
96
|
+
subprocess.run(["git", "config", "user.name", "Your Name"], check=True)
|
|
97
|
+
|
|
98
|
+
# Create some files
|
|
99
|
+
(pathlib.Path(test_dir) / "file1.txt").write_text("content1")
|
|
100
|
+
(pathlib.Path(test_dir) / "dir1").mkdir()
|
|
101
|
+
(pathlib.Path(test_dir) / "dir1" / "file2.txt").write_text("content2")
|
|
102
|
+
|
|
103
|
+
# Create .gitignore and ignore some files
|
|
104
|
+
(pathlib.Path(test_dir) / ".gitignore").write_text(
|
|
105
|
+
"ignored.txt\nnode_modules/"
|
|
106
|
+
)
|
|
107
|
+
(pathlib.Path(test_dir) / "ignored.txt").write_text("should be ignored")
|
|
108
|
+
(pathlib.Path(test_dir) / "node_modules").mkdir()
|
|
109
|
+
(pathlib.Path(test_dir) / "node_modules" / "some_file.txt").write_text(
|
|
110
|
+
"ignored"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
yield pathlib.Path(test_dir)
|
|
114
|
+
|
|
115
|
+
# Clear cached git repo check and restore cwd
|
|
116
|
+
if hasattr(paths.in_git_repo, "_result"):
|
|
117
|
+
del paths.in_git_repo._result
|
|
118
|
+
os.chdir(orig_cwd)
|
|
119
|
+
api.app.state.root = original_root
|
|
120
|
+
shutil.rmtree(test_dir)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.fixture
|
|
124
|
+
def non_git_dir():
|
|
125
|
+
"""A temporary directory that is NOT a git repo."""
|
|
126
|
+
test_dir = tempfile.mkdtemp()
|
|
127
|
+
orig_cwd = os.getcwd()
|
|
128
|
+
original_root = api.app.state.root
|
|
129
|
+
os.chdir(test_dir)
|
|
130
|
+
api.app.state.root = pathlib.Path(test_dir).resolve()
|
|
131
|
+
|
|
132
|
+
# Clear cached git repo check
|
|
133
|
+
if hasattr(paths.in_git_repo, "_result"):
|
|
134
|
+
del paths.in_git_repo._result
|
|
135
|
+
|
|
136
|
+
# Create files (including one that would be "ignored" if git were present)
|
|
137
|
+
(pathlib.Path(test_dir) / "file1.txt").write_text("content1")
|
|
138
|
+
(pathlib.Path(test_dir) / "ignored.txt").write_text("not actually ignored")
|
|
139
|
+
|
|
140
|
+
yield pathlib.Path(test_dir)
|
|
141
|
+
|
|
142
|
+
if hasattr(paths.in_git_repo, "_result"):
|
|
143
|
+
del paths.in_git_repo._result
|
|
144
|
+
os.chdir(orig_cwd)
|
|
145
|
+
api.app.state.root = original_root
|
|
146
|
+
shutil.rmtree(test_dir)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.fixture
|
|
150
|
+
def temp_repo(tmp_path, monkeypatch):
|
|
151
|
+
"""Create a temporary directory and set it as the API root."""
|
|
152
|
+
root = tmp_path / "repo"
|
|
153
|
+
root.mkdir()
|
|
154
|
+
|
|
155
|
+
# Mock api.app.state.root
|
|
156
|
+
original_root = api.app.state.root
|
|
157
|
+
api.app.state.root = root
|
|
158
|
+
|
|
159
|
+
yield root
|
|
160
|
+
|
|
161
|
+
# Restore original root
|
|
162
|
+
api.app.state.root = original_root
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.fixture
|
|
166
|
+
def test_workspace():
|
|
167
|
+
"""A temporary root with a subproject, with auto-start enabled."""
|
|
168
|
+
# Create a temporary root directory
|
|
169
|
+
root_dir = pathlib.Path(tempfile.mkdtemp()).resolve()
|
|
170
|
+
|
|
171
|
+
# Create a subproject directory
|
|
172
|
+
subproject_dir = root_dir / "my-subproject"
|
|
173
|
+
subproject_dir.mkdir()
|
|
174
|
+
|
|
175
|
+
# Set up some tasks in the subproject
|
|
176
|
+
sub_tasks_file = subproject_dir / "tasks.yml"
|
|
177
|
+
data = tasks.Roadmap(
|
|
178
|
+
context="Subproject context",
|
|
179
|
+
tasks=[
|
|
180
|
+
tasks.Task(
|
|
181
|
+
id="sub1",
|
|
182
|
+
description="Sub Task 1",
|
|
183
|
+
status=tasks.TaskStatus.PENDING,
|
|
184
|
+
),
|
|
185
|
+
],
|
|
186
|
+
)
|
|
187
|
+
tasks.save_tasks(sub_tasks_file, data)
|
|
188
|
+
|
|
189
|
+
# Override app state
|
|
190
|
+
original_root = api.app.state.root
|
|
191
|
+
original_tasks_file = api.app.state.tasks_file
|
|
192
|
+
original_auto_start = api.app.state.disable_auto_start
|
|
193
|
+
|
|
194
|
+
api.app.state.root = root_dir
|
|
195
|
+
api.app.state.tasks_file = root_dir / "tasks.yml"
|
|
196
|
+
api.app.state.disable_auto_start = False # Enable auto-start for testing
|
|
197
|
+
|
|
198
|
+
yield root_dir, subproject_dir
|
|
199
|
+
|
|
200
|
+
# Restore app state
|
|
201
|
+
api.app.state.root = original_root
|
|
202
|
+
api.app.state.tasks_file = original_tasks_file
|
|
203
|
+
api.app.state.disable_auto_start = original_auto_start
|
|
204
|
+
shutil.rmtree(root_dir)
|
lemming/api/context.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Helpers to resolve per-project directories and tasks files."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
|
|
5
|
+
import fastapi
|
|
6
|
+
|
|
7
|
+
from .. import paths
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_project_dir(
|
|
11
|
+
app_state: fastapi.datastructures.State, project: str | None = None
|
|
12
|
+
) -> pathlib.Path:
|
|
13
|
+
"""Resolve a project query parameter to its absolute directory path."""
|
|
14
|
+
if not project:
|
|
15
|
+
return app_state.root
|
|
16
|
+
|
|
17
|
+
root = app_state.root
|
|
18
|
+
target = (root / project).resolve()
|
|
19
|
+
if not target.is_relative_to(root):
|
|
20
|
+
raise fastapi.HTTPException(403, "Path is outside the server root")
|
|
21
|
+
if not target.is_dir():
|
|
22
|
+
raise fastapi.HTTPException(400, "Not a directory")
|
|
23
|
+
|
|
24
|
+
return target
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_tasks_file(
|
|
28
|
+
app_state: fastapi.datastructures.State, project: str | None = None
|
|
29
|
+
) -> pathlib.Path:
|
|
30
|
+
"""Resolve a project query parameter to a tasks file path.
|
|
31
|
+
|
|
32
|
+
When *project* is ``None`` or empty the server-wide default is returned.
|
|
33
|
+
Otherwise the value is treated as a relative directory under the server
|
|
34
|
+
root and the tasks file path is derived deterministically.
|
|
35
|
+
"""
|
|
36
|
+
if not project:
|
|
37
|
+
return app_state.tasks_file
|
|
38
|
+
|
|
39
|
+
return paths.get_tasks_file_for_dir(resolve_project_dir(app_state, project))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
|
|
3
|
+
import fastapi
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from lemming.api import context
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MockAppState:
|
|
10
|
+
def __init__(self, root: pathlib.Path, tasks_file: pathlib.Path):
|
|
11
|
+
self.root = root
|
|
12
|
+
self.tasks_file = tasks_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_resolve_project_dir(tmp_path):
|
|
16
|
+
root = tmp_path / "root"
|
|
17
|
+
root.mkdir()
|
|
18
|
+
project = root / "project"
|
|
19
|
+
project.mkdir()
|
|
20
|
+
|
|
21
|
+
app_state = MockAppState(root, root / "tasks.yml")
|
|
22
|
+
|
|
23
|
+
# Default (no project)
|
|
24
|
+
assert context.resolve_project_dir(app_state) == root
|
|
25
|
+
|
|
26
|
+
# Specific project
|
|
27
|
+
assert context.resolve_project_dir(app_state, "project") == project
|
|
28
|
+
|
|
29
|
+
# Outside root
|
|
30
|
+
with pytest.raises(fastapi.HTTPException) as excinfo:
|
|
31
|
+
context.resolve_project_dir(app_state, "..")
|
|
32
|
+
assert excinfo.value.status_code == 403
|
|
33
|
+
|
|
34
|
+
# Not a directory
|
|
35
|
+
not_dir = root / "file.txt"
|
|
36
|
+
not_dir.write_text("hello")
|
|
37
|
+
with pytest.raises(fastapi.HTTPException) as excinfo:
|
|
38
|
+
context.resolve_project_dir(app_state, "file.txt")
|
|
39
|
+
assert excinfo.value.status_code == 400
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_resolve_tasks_file(tmp_path):
|
|
43
|
+
root = tmp_path / "root"
|
|
44
|
+
root.mkdir()
|
|
45
|
+
project = root / "project"
|
|
46
|
+
project.mkdir()
|
|
47
|
+
|
|
48
|
+
default_tasks = root / "tasks.yml"
|
|
49
|
+
app_state = MockAppState(root, default_tasks)
|
|
50
|
+
|
|
51
|
+
# Default
|
|
52
|
+
assert context.resolve_tasks_file(app_state) == default_tasks
|
|
53
|
+
|
|
54
|
+
# Project (should use get_tasks_file_for_dir logic)
|
|
55
|
+
# Since project / tasks.yml doesn't exist, it should be in lemming home
|
|
56
|
+
# with a hash
|
|
57
|
+
tasks_file = context.resolve_tasks_file(app_state, "project")
|
|
58
|
+
assert tasks_file.name == "tasks.yml"
|
|
59
|
+
assert (
|
|
60
|
+
tasks_file.parent.name != "project"
|
|
61
|
+
) # It's a hash, not the project name
|
|
62
|
+
assert tasks_file.is_absolute()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""API routes for browsing and creating directories under the root."""
|
|
2
|
+
|
|
3
|
+
import fastapi
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
router = fastapi.APIRouter()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("/api/directories")
|
|
10
|
+
def list_directories(request: fastapi.Request, path: str = ""):
|
|
11
|
+
"""List subdirectories under the server root for the project picker."""
|
|
12
|
+
root = request.app.state.root
|
|
13
|
+
target = (root / path).resolve() if path else root
|
|
14
|
+
if not target.is_relative_to(root):
|
|
15
|
+
raise fastapi.HTTPException(403, "Path is outside the server root")
|
|
16
|
+
if not target.is_dir():
|
|
17
|
+
raise fastapi.HTTPException(400, "Not a directory")
|
|
18
|
+
|
|
19
|
+
dirs = []
|
|
20
|
+
for item in sorted(target.iterdir()):
|
|
21
|
+
if item.is_dir() and not item.name.startswith("."):
|
|
22
|
+
rel = item.relative_to(root)
|
|
23
|
+
dirs.append({"name": item.name, "path": str(rel)})
|
|
24
|
+
return {"path": path, "directories": dirs}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CreateDirectoryRequest(pydantic.BaseModel):
|
|
28
|
+
"""Request body for creating a directory under the server root."""
|
|
29
|
+
|
|
30
|
+
path: str = ""
|
|
31
|
+
name: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.post("/api/directories")
|
|
35
|
+
def create_directory(
|
|
36
|
+
request: fastapi.Request, dir_request: CreateDirectoryRequest
|
|
37
|
+
):
|
|
38
|
+
"""Create a new directory under the server root."""
|
|
39
|
+
root = request.app.state.root
|
|
40
|
+
parent = (root / dir_request.path).resolve() if dir_request.path else root
|
|
41
|
+
if not parent.is_relative_to(root):
|
|
42
|
+
raise fastapi.HTTPException(403, "Path is outside the server root")
|
|
43
|
+
if not parent.is_dir():
|
|
44
|
+
raise fastapi.HTTPException(400, "Parent is not a directory")
|
|
45
|
+
|
|
46
|
+
new_dir = parent / dir_request.name
|
|
47
|
+
if not new_dir.resolve().is_relative_to(root):
|
|
48
|
+
raise fastapi.HTTPException(
|
|
49
|
+
403, "Target path is outside the server root"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if new_dir.exists():
|
|
53
|
+
raise fastapi.HTTPException(400, "Directory already exists")
|
|
54
|
+
|
|
55
|
+
new_dir.mkdir()
|
|
56
|
+
rel = new_dir.relative_to(root)
|
|
57
|
+
return {"name": new_dir.name, "path": str(rel)}
|