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 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
+ ]
@@ -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
@@ -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()