issueloop 0.2.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.
- issueloop/__init__.py +71 -0
- issueloop/agent_state.py +75 -0
- issueloop/check_env.py +78 -0
- issueloop/cli.py +79 -0
- issueloop/config.py +57 -0
- issueloop/db/__init__.py +11 -0
- issueloop/db/base.py +22 -0
- issueloop/db/local_store.py +102 -0
- issueloop/db/supabase_store.py +88 -0
- issueloop/folder_reader.py +89 -0
- issueloop/live_monitor.py +170 -0
- issueloop/llm.py +81 -0
- issueloop/log_reader.py +16 -0
- issueloop/notify.py +30 -0
- issueloop/permissions.py +83 -0
- issueloop/server.py +59 -0
- issueloop/test_runner.py +106 -0
- issueloop/ticket_creator.py +83 -0
- issueloop-0.2.0.dist-info/METADATA +261 -0
- issueloop-0.2.0.dist-info/RECORD +24 -0
- issueloop-0.2.0.dist-info/WHEEL +5 -0
- issueloop-0.2.0.dist-info/entry_points.txt +2 -0
- issueloop-0.2.0.dist-info/licenses/LICENSE +23 -0
- issueloop-0.2.0.dist-info/top_level.txt +1 -0
issueloop/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .config import use, get_config
|
|
6
|
+
from .db import get_backend
|
|
7
|
+
from .agent_state import TaskStatus
|
|
8
|
+
from . import folder_reader, test_runner,ticket_creator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _backend():
|
|
12
|
+
cfg = get_config()
|
|
13
|
+
return get_backend(cfg.database, path=cfg.database_path)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def scan_repo(repo_path: str):
|
|
17
|
+
out_file = folder_reader.write_inventory(Path(repo_path).resolve())
|
|
18
|
+
import json
|
|
19
|
+
return json.loads(out_file.read_text())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_tests(repo_name: str):
|
|
23
|
+
return test_runner.run_tests(repo_name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_tickets(repo_name: str):
|
|
27
|
+
tickets = ticket_creator.create_tickets_for_repo(repo_name)
|
|
28
|
+
return [_ticket_to_dict(t) for t in tickets]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_top_error(repo_name: str):
|
|
32
|
+
ticket = _backend().dispense_next(repo_name)
|
|
33
|
+
return _ticket_to_dict(ticket) if ticket else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_all_errors(repo_name: Optional[str] = None):
|
|
37
|
+
return [_ticket_to_dict(t) for t in _backend().get_open_tickets(repo_name)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve(ticket_id: str):
|
|
41
|
+
_backend().update_ticket(ticket_id, status=TaskStatus.DONE)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fail(ticket_id: str):
|
|
45
|
+
_backend().update_ticket(ticket_id, status=TaskStatus.FAILED)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cleanup(older_than_days: Optional[int] = None, repo: Optional[str] = None):
|
|
49
|
+
days = older_than_days if older_than_days is not None else get_config().retention_days
|
|
50
|
+
return _backend().purge_old(days, repo)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ticket_to_dict(ticket):
|
|
54
|
+
return {
|
|
55
|
+
"id": ticket.id,
|
|
56
|
+
"repo": ticket.repo,
|
|
57
|
+
"priority": ticket.priority.value,
|
|
58
|
+
"status": ticket.status.value,
|
|
59
|
+
"error_summary": ticket.error_summary,
|
|
60
|
+
"raw_log_ref": ticket.raw_log_ref,
|
|
61
|
+
"command": ticket.command,
|
|
62
|
+
"test_id": ticket.test_id,
|
|
63
|
+
"created_at": str(ticket.created_at),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"use", "get_config",
|
|
69
|
+
"scan_repo", "run_tests", "create_tickets",
|
|
70
|
+
"get_top_error", "get_all_errors", "resolve", "fail", "cleanup",
|
|
71
|
+
]
|
issueloop/agent_state.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TaskStatus(str, Enum):
|
|
8
|
+
PENDING = "pending"
|
|
9
|
+
IN_PROGRESS = "in_progress"
|
|
10
|
+
BLOCKED = "blocked"
|
|
11
|
+
DONE = "done"
|
|
12
|
+
FAILED = "failed"
|
|
13
|
+
NEEDS_HUMAN = "needs_human"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TicketPriority(str, Enum):
|
|
17
|
+
BLOCKING = "blocking"
|
|
18
|
+
HIGH = "high"
|
|
19
|
+
NORMAL = "normal"
|
|
20
|
+
LOW = "low"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SubTask:
|
|
25
|
+
id: str
|
|
26
|
+
description: str
|
|
27
|
+
depends_on: list[str] = field(default_factory=list)
|
|
28
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
29
|
+
result: Optional[dict[str, Any]] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Ticket:
|
|
34
|
+
id: str
|
|
35
|
+
repo: str
|
|
36
|
+
error_summary: str
|
|
37
|
+
raw_log_ref: str
|
|
38
|
+
priority: TicketPriority
|
|
39
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
40
|
+
attempts: int = 0
|
|
41
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
42
|
+
resolved_at: Optional[datetime] = None
|
|
43
|
+
escalation_summary: Optional[str] = None
|
|
44
|
+
command: Optional[str] = None
|
|
45
|
+
test_id: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class AgentState:
|
|
50
|
+
allowed_commands: list[str] = field(default_factory=list)
|
|
51
|
+
create_pr_permission: bool = False
|
|
52
|
+
supabase_write_permission: bool = False
|
|
53
|
+
repo: str = ""
|
|
54
|
+
branch: str = ""
|
|
55
|
+
current_task_id: str = ""
|
|
56
|
+
current_task: Optional[SubTask] = None
|
|
57
|
+
previous_task_and_result: Optional[dict[str, Any]] = None
|
|
58
|
+
next_task_id: Optional[str] = None
|
|
59
|
+
queue_independent_tasks: list[SubTask] = field(default_factory=list)
|
|
60
|
+
queue_dependent_task: list[SubTask] = field(default_factory=list)
|
|
61
|
+
active_tickets: list[Ticket] = field(default_factory=list)
|
|
62
|
+
message_from_user: Optional[str] = None
|
|
63
|
+
pending_confirmation: bool = False
|
|
64
|
+
goal: str = ""
|
|
65
|
+
what_to_do: list[str] = field(default_factory=list)
|
|
66
|
+
what_not_to_do: list[str] = field(default_factory=list)
|
|
67
|
+
overall_points: int = 0
|
|
68
|
+
current_task_points: int = 0
|
|
69
|
+
max_retries_per_ticket: int = 3
|
|
70
|
+
log_cache_ref: str = ""
|
|
71
|
+
last_error: Optional[str] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def new_run_state(repo: str, goal: str, allowed_commands: list[str]):
|
|
75
|
+
return AgentState(repo=repo, goal=goal, allowed_commands=allowed_commands)
|
issueloop/check_env.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .config import get_config
|
|
8
|
+
|
|
9
|
+
OLLAMA_URL = "http://localhost:11434"
|
|
10
|
+
REQUIRED_MODELS = {"qwen2.5-coder:7b": "blocking"}
|
|
11
|
+
CORE_PY_PACKAGES = ["requests", "yaml", "pathspec"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check(label: str, ok: bool, detail: str = "", blocking: bool = True):
|
|
15
|
+
status = "PASS" if ok else ("FAIL" if blocking else "WARN")
|
|
16
|
+
print(f"[{status}] {label}" + (f" — {detail}" if detail else ""))
|
|
17
|
+
return ok or not blocking
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
all_ok = True
|
|
22
|
+
cfg = get_config()
|
|
23
|
+
|
|
24
|
+
for pkg in CORE_PY_PACKAGES:
|
|
25
|
+
try:
|
|
26
|
+
__import__(pkg)
|
|
27
|
+
check(f"python package: {pkg}", True)
|
|
28
|
+
except ImportError:
|
|
29
|
+
all_ok &= check(f"python package: {pkg}", False, "run: pip install -r requirements.txt")
|
|
30
|
+
|
|
31
|
+
if cfg.database == "local":
|
|
32
|
+
check("database backend: local (sqlite)", True, "no external service required")
|
|
33
|
+
elif cfg.database == "supabase":
|
|
34
|
+
import os
|
|
35
|
+
configured = bool(os.environ.get("SUPABASE_URL")) and bool(os.environ.get("SUPABASE_KEY"))
|
|
36
|
+
all_ok &= check("SUPABASE_URL / SUPABASE_KEY set", configured,
|
|
37
|
+
"" if configured else "copy .env.example to .env and fill them in")
|
|
38
|
+
try:
|
|
39
|
+
import supabase
|
|
40
|
+
check("python package: supabase", True)
|
|
41
|
+
except ImportError:
|
|
42
|
+
all_ok &= check("python package: supabase", False, 'run: pip install -e ".[supabase]"')
|
|
43
|
+
|
|
44
|
+
if cfg.llm.provider == "ollama":
|
|
45
|
+
ollama_path = shutil.which("ollama")
|
|
46
|
+
all_ok &= check("ollama binary on PATH", ollama_path is not None, ollama_path or "not found — run setup_ollama.sh")
|
|
47
|
+
|
|
48
|
+
server_up = False
|
|
49
|
+
try:
|
|
50
|
+
r = requests.get(f"{OLLAMA_URL}/api/tags", timeout=3)
|
|
51
|
+
server_up = r.status_code == 200
|
|
52
|
+
except requests.exceptions.ConnectionError:
|
|
53
|
+
pass
|
|
54
|
+
all_ok &= check("ollama server responding", server_up, f"{OLLAMA_URL}/api/tags — run: ollama serve")
|
|
55
|
+
|
|
56
|
+
pulled_models = set()
|
|
57
|
+
if server_up:
|
|
58
|
+
try:
|
|
59
|
+
tags = requests.get(f"{OLLAMA_URL}/api/tags", timeout=5).json()
|
|
60
|
+
pulled_models = {m["name"] for m in tags.get("models", [])}
|
|
61
|
+
except Exception as e:
|
|
62
|
+
check("could not parse ollama /api/tags response", False, str(e))
|
|
63
|
+
|
|
64
|
+
for model, level in REQUIRED_MODELS.items():
|
|
65
|
+
have_it = any(model in m for m in pulled_models)
|
|
66
|
+
all_ok &= check(f"model pulled: {model}", have_it, "" if have_it else f"run: ollama pull {model}",
|
|
67
|
+
blocking=(level == "blocking"))
|
|
68
|
+
else:
|
|
69
|
+
check(f"llm provider: {cfg.llm.provider}", bool(cfg.llm.api_key),
|
|
70
|
+
"" if cfg.llm.api_key else "set llm.apiKey via issueloop.use(llm={...})")
|
|
71
|
+
|
|
72
|
+
print()
|
|
73
|
+
print("ALL BLOCKING CHECKS PASSED" if all_ok else "BLOCKING CHECKS FAILED — fix the FAIL lines above before proceeding")
|
|
74
|
+
return 0 if all_ok else 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
sys.exit(main())
|
issueloop/cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import issueloop
|
|
6
|
+
from . import check_env
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(prog="issueloop")
|
|
11
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
12
|
+
|
|
13
|
+
sub.add_parser("check-env", help="verify ollama, models, and Supabase env are ready")
|
|
14
|
+
|
|
15
|
+
p_scan = sub.add_parser("scan", help="build a file inventory for a repo")
|
|
16
|
+
p_scan.add_argument("repo_path")
|
|
17
|
+
|
|
18
|
+
p_test = sub.add_parser("test", help="run test_manifest.json commands for a repo, write log cache")
|
|
19
|
+
p_test.add_argument("repo_name")
|
|
20
|
+
|
|
21
|
+
p_tickets = sub.add_parser("tickets", help="create tickets from failing log entries")
|
|
22
|
+
p_tickets.add_argument("repo_name")
|
|
23
|
+
|
|
24
|
+
p_next = sub.add_parser("next", help="dispense the next ticket for a repo, one at a time")
|
|
25
|
+
p_next.add_argument("repo_name")
|
|
26
|
+
|
|
27
|
+
p_cleanup = sub.add_parser("cleanup", help="delete done/failed tickets older than N days")
|
|
28
|
+
p_cleanup.add_argument("--days", type=int, default=None)
|
|
29
|
+
p_cleanup.add_argument("--repo", default=None)
|
|
30
|
+
|
|
31
|
+
p_serve = sub.add_parser("serve", help="start the local HTTP bridge for non-Python callers")
|
|
32
|
+
p_serve.add_argument("--port", type=int, default=8787)
|
|
33
|
+
|
|
34
|
+
args = parser.parse_args()
|
|
35
|
+
|
|
36
|
+
if args.command == "check-env":
|
|
37
|
+
return check_env.main()
|
|
38
|
+
|
|
39
|
+
if args.command == "scan":
|
|
40
|
+
inventory = issueloop.scan_repo(args.repo_path)
|
|
41
|
+
print(f"scanned {inventory['file_count']} files — {inventory['language_breakdown']}")
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
if args.command == "test":
|
|
45
|
+
for r in issueloop.run_tests(args.repo_name):
|
|
46
|
+
status = "OK" if r["exit_code"] == 0 else "FAIL"
|
|
47
|
+
print(f"[{status}] {r['test_id']} (exit {r['exit_code']})")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
if args.command == "tickets":
|
|
51
|
+
created = issueloop.create_tickets(args.repo_name)
|
|
52
|
+
print(f"created {len(created)} ticket(s)")
|
|
53
|
+
for t in created:
|
|
54
|
+
print(f" [{t['priority']}] {t['error_summary']}")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
if args.command == "next":
|
|
58
|
+
ticket = issueloop.get_top_error(args.repo_name)
|
|
59
|
+
if ticket is None:
|
|
60
|
+
print("no pending tickets")
|
|
61
|
+
return 0
|
|
62
|
+
print(json.dumps(ticket, indent=2))
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
if args.command == "cleanup":
|
|
66
|
+
removed = issueloop.cleanup(older_than_days=args.days, repo=args.repo)
|
|
67
|
+
print(f"removed {removed} old ticket(s)")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
if args.command == "serve":
|
|
71
|
+
from . import server
|
|
72
|
+
server.serve(port=args.port)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
sys.exit(main())
|
issueloop/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class LLMConfig:
|
|
6
|
+
provider: str = "ollama"
|
|
7
|
+
model: str = "qwen2.5-coder:7b"
|
|
8
|
+
api_key: Optional[str] = None
|
|
9
|
+
base_url: str = "http://localhost:11434"
|
|
10
|
+
token_size: int = 1024
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class NotifyConfig:
|
|
14
|
+
email: Optional[str] = None
|
|
15
|
+
webhook: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Config:
|
|
20
|
+
database: str = "local"
|
|
21
|
+
database_path: Optional[str] = None
|
|
22
|
+
retention_days: int = 30
|
|
23
|
+
llm: LLMConfig = field(default_factory=LLMConfig)
|
|
24
|
+
notify: NotifyConfig = field(default_factory=NotifyConfig)
|
|
25
|
+
|
|
26
|
+
_config = Config()
|
|
27
|
+
|
|
28
|
+
def use(
|
|
29
|
+
database: str = "local",
|
|
30
|
+
database_path: Optional[str] = None,
|
|
31
|
+
retention_days: int = 30,
|
|
32
|
+
llm: Optional[dict] = None,
|
|
33
|
+
notify: Optional[dict] = None):
|
|
34
|
+
global _config
|
|
35
|
+
llm = llm or {}
|
|
36
|
+
notify = notify or {}
|
|
37
|
+
_config = Config(
|
|
38
|
+
database=database,
|
|
39
|
+
database_path=database_path,
|
|
40
|
+
retention_days=retention_days,
|
|
41
|
+
llm=LLMConfig(
|
|
42
|
+
provider=llm.get("provider", "ollama"),
|
|
43
|
+
model=llm.get("model", "qwen2.5-coder:7b"),
|
|
44
|
+
api_key=llm.get("apiKey") or llm.get("api_key"),
|
|
45
|
+
base_url=llm.get("baseUrl") or llm.get("base_url", "http://localhost:11434"),
|
|
46
|
+
token_size=llm.get("tokenSize") or llm.get("token_size", 1024),
|
|
47
|
+
),
|
|
48
|
+
notify=NotifyConfig(
|
|
49
|
+
email=notify.get("email"),
|
|
50
|
+
webhook=notify.get("webhook"),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
return _config
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_config():
|
|
57
|
+
return _config
|
issueloop/db/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .base import TicketStoreBackend
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_backend(kind: str = "local", **kwargs) -> TicketStoreBackend:
|
|
5
|
+
if kind == "local":
|
|
6
|
+
from .local_store import LocalStore
|
|
7
|
+
return LocalStore(db_path=kwargs.get("path"))
|
|
8
|
+
if kind == "supabase":
|
|
9
|
+
from .supabase_store import SupabaseStore
|
|
10
|
+
return SupabaseStore()
|
|
11
|
+
raise ValueError(f"unknown database backend '{kind}' — expected 'local' or 'supabase'")
|
issueloop/db/base.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from ..agent_state import Ticket
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TicketStoreBackend(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def create_ticket(self, ticket: Ticket): ...
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def update_ticket(self, ticket_id: str, **fields): ...
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def get_open_tickets(self, repo: Optional[str] = None): ...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def dispense_next(self, repo: Optional[str] = None):...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def purge_old(self, older_than_days: int, repo: Optional[str] = None):...
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
|
|
2
|
+
import sqlite3
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ..agent_state import TaskStatus, Ticket, TicketPriority
|
|
9
|
+
from .base import TicketStoreBackend
|
|
10
|
+
|
|
11
|
+
SCHEMA = """
|
|
12
|
+
CREATE TABLE IF NOT EXISTS tickets (
|
|
13
|
+
id TEXT PRIMARY KEY,
|
|
14
|
+
repo TEXT NOT NULL,
|
|
15
|
+
error_summary TEXT NOT NULL,
|
|
16
|
+
raw_log_ref TEXT,
|
|
17
|
+
priority TEXT NOT NULL,
|
|
18
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
19
|
+
created_at TEXT NOT NULL,
|
|
20
|
+
resolved_at TEXT,
|
|
21
|
+
command TEXT,
|
|
22
|
+
test_id TEXT
|
|
23
|
+
);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_repo_status ON tickets (repo, status);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_created_at ON tickets (created_at);
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
PRIORITY_ORDER = {"blocking": 0, "high": 1, "normal": 2, "low": 3}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LocalStore(TicketStoreBackend):
|
|
32
|
+
def __init__(self, db_path: Optional[Path] = None):
|
|
33
|
+
default = Path(__file__).resolve().parent.parent.parent / "data" / "issueloop.db"
|
|
34
|
+
self.db_path = Path(db_path) if db_path else default
|
|
35
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
|
37
|
+
self._conn.row_factory = sqlite3.Row
|
|
38
|
+
self._conn.executescript(SCHEMA)
|
|
39
|
+
self._conn.commit()
|
|
40
|
+
|
|
41
|
+
def _row_to_ticket(self, row: sqlite3.Row):
|
|
42
|
+
return Ticket(
|
|
43
|
+
id=row["id"],
|
|
44
|
+
repo=row["repo"],
|
|
45
|
+
error_summary=row["error_summary"],
|
|
46
|
+
raw_log_ref=row["raw_log_ref"],
|
|
47
|
+
priority=TicketPriority(row["priority"]),
|
|
48
|
+
status=TaskStatus(row["status"]),
|
|
49
|
+
created_at=row["created_at"],
|
|
50
|
+
resolved_at=row["resolved_at"],
|
|
51
|
+
command=row["command"],
|
|
52
|
+
test_id=row["test_id"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def create_ticket(self, ticket: Ticket):
|
|
56
|
+
if not ticket.id:
|
|
57
|
+
ticket.id = str(uuid.uuid4())
|
|
58
|
+
self._conn.execute(
|
|
59
|
+
"INSERT INTO tickets (id, repo, error_summary, raw_log_ref, priority, status, created_at, command, test_id) "
|
|
60
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
61
|
+
(ticket.id, ticket.repo, ticket.error_summary, ticket.raw_log_ref,
|
|
62
|
+
ticket.priority.value, ticket.status.value,
|
|
63
|
+
datetime.now(timezone.utc).isoformat(), ticket.command, ticket.test_id),
|
|
64
|
+
)
|
|
65
|
+
self._conn.commit()
|
|
66
|
+
return ticket
|
|
67
|
+
|
|
68
|
+
def update_ticket(self, ticket_id: str, **fields):
|
|
69
|
+
clean = {k: (v.value if hasattr(v, "value") else v) for k, v in fields.items()}
|
|
70
|
+
set_clause = ", ".join(f"{k} = ?" for k in clean)
|
|
71
|
+
self._conn.execute(f"UPDATE tickets SET {set_clause} WHERE id = ?", (*clean.values(), ticket_id))
|
|
72
|
+
self._conn.commit()
|
|
73
|
+
|
|
74
|
+
def get_open_tickets(self, repo: Optional[str] = None):
|
|
75
|
+
query = "SELECT * FROM tickets WHERE status NOT IN ('done', 'in_progress')"
|
|
76
|
+
params: tuple = ()
|
|
77
|
+
if repo:
|
|
78
|
+
query += " AND repo = ?"
|
|
79
|
+
params = (repo,)
|
|
80
|
+
rows = self._conn.execute(query, params).fetchall()
|
|
81
|
+
return [self._row_to_ticket(r) for r in rows]
|
|
82
|
+
|
|
83
|
+
def dispense_next(self, repo: Optional[str] = None):
|
|
84
|
+
candidates = self.get_open_tickets(repo)
|
|
85
|
+
if not candidates:
|
|
86
|
+
return None
|
|
87
|
+
candidates.sort(key=lambda t: (PRIORITY_ORDER[t.priority.value], t.created_at))
|
|
88
|
+
ticket = candidates[0]
|
|
89
|
+
self.update_ticket(ticket.id, status=TaskStatus.IN_PROGRESS)
|
|
90
|
+
ticket.status = TaskStatus.IN_PROGRESS
|
|
91
|
+
return ticket
|
|
92
|
+
|
|
93
|
+
def purge_old(self, older_than_days: int, repo: Optional[str] = None):
|
|
94
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=older_than_days)).isoformat()
|
|
95
|
+
query = "DELETE FROM tickets WHERE status IN ('done', 'failed') AND created_at < ?"
|
|
96
|
+
params: tuple = (cutoff,)
|
|
97
|
+
if repo:
|
|
98
|
+
query += " AND repo = ?"
|
|
99
|
+
params = (cutoff, repo)
|
|
100
|
+
cur = self._conn.execute(query, params)
|
|
101
|
+
self._conn.commit()
|
|
102
|
+
return cur.rowcount
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from supabase import create_client
|
|
7
|
+
|
|
8
|
+
from ..agent_state import TaskStatus, Ticket, TicketPriority
|
|
9
|
+
from .base import TicketStoreBackend
|
|
10
|
+
|
|
11
|
+
TABLE = "tickets"
|
|
12
|
+
AUDIT_LOG = Path(__file__).resolve().parent.parent.parent / "data" / "logs" / "tickets_audit.jsonl"
|
|
13
|
+
|
|
14
|
+
PRIORITY_ORDER = {"blocking": 0, "high": 1, "normal": 2, "low": 3}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SupabaseStore(TicketStoreBackend):
|
|
18
|
+
def __init__(self):
|
|
19
|
+
from dotenv import load_dotenv
|
|
20
|
+
load_dotenv()
|
|
21
|
+
url = os.environ.get("SUPABASE_URL")
|
|
22
|
+
key = os.environ.get("SUPABASE_KEY")
|
|
23
|
+
if not url or not key:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
"SUPABASE_URL / SUPABASE_KEY not set. Copy .env.example to .env and fill them in, "
|
|
26
|
+
"or pass issueloop.use(database='local') instead."
|
|
27
|
+
)
|
|
28
|
+
self._client = create_client(url, key)
|
|
29
|
+
|
|
30
|
+
def _audit(self, event: str, ticket_id: str, detail: dict):
|
|
31
|
+
AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with AUDIT_LOG.open("a") as f:
|
|
33
|
+
f.write(json.dumps({
|
|
34
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
35
|
+
"event": event, "ticket_id": ticket_id, **detail,
|
|
36
|
+
}) + "\n")
|
|
37
|
+
|
|
38
|
+
def _row_to_ticket(self, row: dict):
|
|
39
|
+
return Ticket(
|
|
40
|
+
id=row["id"], repo=row["repo"], error_summary=row["error_summary"],
|
|
41
|
+
raw_log_ref=row["raw_log_ref"], priority=TicketPriority(row["priority"]),
|
|
42
|
+
status=TaskStatus(row["status"]),
|
|
43
|
+
created_at=row.get("created_at") or datetime.now(timezone.utc).isoformat(),
|
|
44
|
+
resolved_at=row.get("resolved_at"), command=row.get("command"), test_id=row.get("test_id"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def create_ticket(self, ticket: Ticket):
|
|
48
|
+
row = {
|
|
49
|
+
"id": ticket.id, "repo": ticket.repo, "error_summary": ticket.error_summary,
|
|
50
|
+
"raw_log_ref": ticket.raw_log_ref, "priority": ticket.priority.value,
|
|
51
|
+
"status": ticket.status.value, "command": ticket.command, "test_id": ticket.test_id,
|
|
52
|
+
}
|
|
53
|
+
self._client.table(TABLE).insert(row).execute()
|
|
54
|
+
self._audit("created", ticket.id, {"priority": ticket.priority.value, "error_summary": ticket.error_summary})
|
|
55
|
+
return ticket
|
|
56
|
+
|
|
57
|
+
def update_ticket(self, ticket_id: str, **fields):
|
|
58
|
+
clean = {k: (v.value if hasattr(v, "value") else v) for k, v in fields.items()}
|
|
59
|
+
self._client.table(TABLE).update(clean).eq("id", ticket_id).execute()
|
|
60
|
+
self._audit("updated", ticket_id, clean)
|
|
61
|
+
|
|
62
|
+
def get_open_tickets(self, repo: Optional[str] = None):
|
|
63
|
+
q = self._client.table(TABLE).select("*")
|
|
64
|
+
if repo:
|
|
65
|
+
q = q.eq("repo", repo)
|
|
66
|
+
rows = q.execute().data
|
|
67
|
+
return [self._row_to_ticket(r) for r in rows if r["status"] not in ("done", "in_progress")]
|
|
68
|
+
|
|
69
|
+
def dispense_next(self, repo: Optional[str] = None):
|
|
70
|
+
candidates = self.get_open_tickets(repo)
|
|
71
|
+
if not candidates:
|
|
72
|
+
return None
|
|
73
|
+
candidates.sort(key=lambda t: (PRIORITY_ORDER[t.priority.value], t.created_at))
|
|
74
|
+
ticket = candidates[0]
|
|
75
|
+
self.update_ticket(ticket.id, status=TaskStatus.IN_PROGRESS)
|
|
76
|
+
ticket.status = TaskStatus.IN_PROGRESS
|
|
77
|
+
return ticket
|
|
78
|
+
|
|
79
|
+
def purge_old(self, older_than_days: int, repo: Optional[str] = None):
|
|
80
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=older_than_days)).isoformat()
|
|
81
|
+
q = self._client.table(TABLE).select("id").in_("status", ["done", "failed"]).lt("created_at", cutoff)
|
|
82
|
+
if repo:
|
|
83
|
+
q = q.eq("repo", repo)
|
|
84
|
+
rows = q.execute().data
|
|
85
|
+
ids = [r["id"] for r in rows]
|
|
86
|
+
for tid in ids:
|
|
87
|
+
self._client.table(TABLE).delete().eq("id", tid).execute()
|
|
88
|
+
return len(ids)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pathspec
|
|
6
|
+
|
|
7
|
+
EXT_LANGUAGE_MAP = {
|
|
8
|
+
".py": "python",
|
|
9
|
+
".go": "go",
|
|
10
|
+
".js": "javascript",
|
|
11
|
+
".ts": "typescript",
|
|
12
|
+
".jsx": "javascript",
|
|
13
|
+
".tsx": "typescript",
|
|
14
|
+
".rs": "rust",
|
|
15
|
+
".java": "java",
|
|
16
|
+
".c": "c",
|
|
17
|
+
".cpp": "cpp",
|
|
18
|
+
".rb": "ruby",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
ALWAYS_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "vectordb"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_gitignore_spec(repo_path: Path):
|
|
25
|
+
gitignore = repo_path / ".gitignore"
|
|
26
|
+
lines = gitignore.read_text().splitlines() if gitignore.exists() else []
|
|
27
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def walk_repo(repo_path: Path):
|
|
31
|
+
spec = load_gitignore_spec(repo_path)
|
|
32
|
+
files = []
|
|
33
|
+
lang_counts: dict[str, int] = {}
|
|
34
|
+
for path in repo_path.rglob("*"):
|
|
35
|
+
if path.is_dir():
|
|
36
|
+
continue
|
|
37
|
+
if any(part in ALWAYS_SKIP_DIRS for part in path.parts):
|
|
38
|
+
continue
|
|
39
|
+
rel = path.relative_to(repo_path)
|
|
40
|
+
if spec.match_file(str(rel)):
|
|
41
|
+
continue
|
|
42
|
+
ext = path.suffix
|
|
43
|
+
language = EXT_LANGUAGE_MAP.get(ext, "unknown")
|
|
44
|
+
try:
|
|
45
|
+
size = path.stat().st_size
|
|
46
|
+
except OSError:
|
|
47
|
+
continue
|
|
48
|
+
files.append({
|
|
49
|
+
"path": str(rel),
|
|
50
|
+
"ext": ext,
|
|
51
|
+
"language": language,
|
|
52
|
+
"size_bytes": size,
|
|
53
|
+
})
|
|
54
|
+
lang_counts[language] = lang_counts.get(language, 0) + 1
|
|
55
|
+
return {
|
|
56
|
+
"repo_path": str(repo_path),
|
|
57
|
+
"file_count": len(files),
|
|
58
|
+
"language_breakdown": lang_counts,
|
|
59
|
+
"files": files,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write_inventory(repo_path: Path):
|
|
64
|
+
inventory = walk_repo(repo_path)
|
|
65
|
+
out_dir = Path(__file__).resolve().parent.parent / "data" / "logs"
|
|
66
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
out_file = out_dir / f"{repo_path.name}_files.json"
|
|
68
|
+
out_file.write_text(json.dumps(inventory, indent=2))
|
|
69
|
+
return out_file
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main():
|
|
73
|
+
if len(sys.argv) != 2:
|
|
74
|
+
print("ISSUELOOP:: usage: python -m issueloop.folder_reader <path-to-repo>")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
repo_path = Path(sys.argv[1]).resolve()
|
|
77
|
+
if not repo_path.is_dir():
|
|
78
|
+
print(f"ISSUELOOP:: There is no directory: {repo_path}")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
out_file = write_inventory(repo_path)
|
|
81
|
+
inventory = json.loads(out_file.read_text())
|
|
82
|
+
|
|
83
|
+
print(f"ISSUELOOP:: scanned {inventory['file_count']} files in {repo_path}")
|
|
84
|
+
print(f"ISSUELOOP:: language breakdown: {inventory['language_breakdown']}")
|
|
85
|
+
print(f"ISSUELOOP:: written: {out_file}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|