chronicle-devkit 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.
- chronicle/__init__.py +1 -0
- chronicle/ai/__init__.py +0 -0
- chronicle/ai/base.py +7 -0
- chronicle/ai/factory.py +23 -0
- chronicle/ai/google_ai.py +14 -0
- chronicle/ai/openai.py +14 -0
- chronicle/analysis/__init__.py +0 -0
- chronicle/analysis/analyzers/__init__.py +0 -0
- chronicle/analysis/analyzers/base.py +15 -0
- chronicle/analysis/analyzers/django_migrations.py +74 -0
- chronicle/analysis/analyzers/git.py +37 -0
- chronicle/analysis/context.py +12 -0
- chronicle/analysis/engine.py +18 -0
- chronicle/cli/__init__.py +1 -0
- chronicle/cli/commands/__init__.py +0 -0
- chronicle/cli/commands/analyze.py +73 -0
- chronicle/cli/commands/config.py +79 -0
- chronicle/cli/commands/init.py +47 -0
- chronicle/cli/commands/interpret.py +48 -0
- chronicle/cli/commands/scan.py +87 -0
- chronicle/cli/commands/show.py +106 -0
- chronicle/cli/commands/status.py +26 -0
- chronicle/cli/commands/version.py +6 -0
- chronicle/cli/main.py +30 -0
- chronicle/config/__init__.py +0 -0
- chronicle/config/credentials.py +17 -0
- chronicle/config/loader.py +21 -0
- chronicle/config/manager.py +52 -0
- chronicle/integrations/__init__.py +0 -0
- chronicle/integrations/git.py +16 -0
- chronicle/interpretation/__init__.py +0 -0
- chronicle/interpretation/context.py +8 -0
- chronicle/interpretation/context_builder.py +147 -0
- chronicle/interpretation/interpreter.py +12 -0
- chronicle/interpretation/prompts.py +51 -0
- chronicle/project/__init__.py +0 -0
- chronicle/project/discovery.py +9 -0
- chronicle/project/initializer.py +45 -0
- chronicle/project/status.py +25 -0
- chronicle/scanning/__init__.py +0 -0
- chronicle/scanning/context.py +10 -0
- chronicle/scanning/engine.py +16 -0
- chronicle/scanning/scanners/__init__.py +0 -0
- chronicle/scanning/scanners/base.py +17 -0
- chronicle/scanning/scanners/django_migrations.py +156 -0
- chronicle/scanning/scanners/django_model.py +12 -0
- chronicle/scanning/scanners/git.py +94 -0
- chronicle/scanning/scanners/git_models.py +6 -0
- chronicle/storage/__init__.py +0 -0
- chronicle/storage/analysis_state.py +44 -0
- chronicle/storage/database.py +9 -0
- chronicle/storage/findings.py +53 -0
- chronicle/storage/models.py +20 -0
- chronicle/storage/observations.py +93 -0
- chronicle/storage/scan_state.py +44 -0
- chronicle/storage/schema.py +47 -0
- chronicle_devkit-0.1.0.dist-info/METADATA +233 -0
- chronicle_devkit-0.1.0.dist-info/RECORD +61 -0
- chronicle_devkit-0.1.0.dist-info/WHEEL +5 -0
- chronicle_devkit-0.1.0.dist-info/entry_points.txt +2 -0
- chronicle_devkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from chronicle.storage.observations import ObservationRepo
|
|
3
|
+
from chronicle.storage.database import connect
|
|
4
|
+
from chronicle.config.loader import get_chronicle_directory
|
|
5
|
+
from chronicle.project.discovery import find_project_root
|
|
6
|
+
|
|
7
|
+
def show(id:int | None = typer.Option(None, "--id")):
|
|
8
|
+
"""Shows all the observations done by chronicle or shows observation by id using --id."""
|
|
9
|
+
project_root = find_project_root()
|
|
10
|
+
if project_root is None:
|
|
11
|
+
typer.echo("Error: could not find git repo.",err=True)
|
|
12
|
+
raise typer.Exit(code=1)
|
|
13
|
+
db_path = (get_chronicle_directory(project_root) / "chronicle.db")
|
|
14
|
+
conn = connect(db_path)
|
|
15
|
+
if id:
|
|
16
|
+
observation, = ObservationRepo(conn).list_all(id)
|
|
17
|
+
typer.echo()
|
|
18
|
+
typer.echo(f"Observation #{id}")
|
|
19
|
+
typer.echo("─" * 44)
|
|
20
|
+
typer.echo()
|
|
21
|
+
|
|
22
|
+
typer.echo(f"{'Source:':<14}{observation.source}")
|
|
23
|
+
typer.echo(f"{'Type:':<14}{observation.type}")
|
|
24
|
+
typer.echo(f"{'External ID:':<14}{observation.external_id}")
|
|
25
|
+
typer.echo(
|
|
26
|
+
f"{'Timestamp:':<14}"
|
|
27
|
+
f"{observation.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
typer.echo()
|
|
31
|
+
typer.echo("Data")
|
|
32
|
+
typer.echo("─" * 44)
|
|
33
|
+
|
|
34
|
+
data = observation.data
|
|
35
|
+
|
|
36
|
+
if "app" in data:
|
|
37
|
+
typer.echo(f"{'App:':<14}{data['app']}")
|
|
38
|
+
|
|
39
|
+
if "name" in data:
|
|
40
|
+
typer.echo(f"{'Migration:':<14}{data['name']}")
|
|
41
|
+
|
|
42
|
+
if "dependencies" in data:
|
|
43
|
+
typer.echo()
|
|
44
|
+
typer.echo("Dependencies:")
|
|
45
|
+
|
|
46
|
+
for dependency in data["dependencies"]:
|
|
47
|
+
typer.echo(f" • {dependency[0]}:{dependency[1]}")
|
|
48
|
+
|
|
49
|
+
if "operations" in data:
|
|
50
|
+
typer.echo()
|
|
51
|
+
typer.echo("Operations:")
|
|
52
|
+
|
|
53
|
+
for operation in data["operations"]:
|
|
54
|
+
typer.echo(
|
|
55
|
+
f" • {operation['operation']}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
details = operation.get("details", {})
|
|
59
|
+
|
|
60
|
+
if details.get("model"):
|
|
61
|
+
typer.echo(
|
|
62
|
+
f" Model: {details['model']}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if details.get("field"):
|
|
66
|
+
typer.echo(
|
|
67
|
+
f" Field: {details['field']}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
typer.echo()
|
|
71
|
+
typer.echo("─" * 44)
|
|
72
|
+
else:
|
|
73
|
+
observations = ObservationRepo(conn).list_all()
|
|
74
|
+
|
|
75
|
+
typer.echo()
|
|
76
|
+
typer.echo("Chronicle Observations")
|
|
77
|
+
typer.echo("─" * 75)
|
|
78
|
+
typer.echo()
|
|
79
|
+
|
|
80
|
+
if not observations:
|
|
81
|
+
typer.echo("No observations found.")
|
|
82
|
+
typer.echo()
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
typer.echo(
|
|
86
|
+
f"{'ID':<5}"
|
|
87
|
+
f"{'SOURCE':<12}"
|
|
88
|
+
f"{'TYPE':<15}"
|
|
89
|
+
f"EXTERNAL ID"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
typer.echo("─" * 75)
|
|
93
|
+
|
|
94
|
+
for observation in observations:
|
|
95
|
+
formatted_external_id = observation.external_id[:7] if observation.source == "git" else observation.external_id
|
|
96
|
+
|
|
97
|
+
typer.echo(
|
|
98
|
+
f"{observation.id:<5}"
|
|
99
|
+
f"{observation.source:<12}"
|
|
100
|
+
f"{observation.type:<15}"
|
|
101
|
+
f"{formatted_external_id}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
typer.echo()
|
|
105
|
+
typer.echo("─" * 75)
|
|
106
|
+
typer.echo(f"{len(observations)} observations")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from chronicle.project.status import get_status
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
def status():
|
|
6
|
+
"""Show the current chronicle project status."""
|
|
7
|
+
project_status=get_status()
|
|
8
|
+
if project_status is None:
|
|
9
|
+
typer.echo("Error: could not find a Git repo in the project.")
|
|
10
|
+
raise typer.Exit(code=1)
|
|
11
|
+
|
|
12
|
+
typer.echo("")
|
|
13
|
+
typer.echo(f"Project: {project_status.project_root.name}")
|
|
14
|
+
typer.echo(f"Root: {project_status.project_root}")
|
|
15
|
+
if project_status.chronicle_initialized:
|
|
16
|
+
typer.echo("Chronicle: initialized")
|
|
17
|
+
else:
|
|
18
|
+
typer.echo("Chronicle: not initialized")
|
|
19
|
+
if project_status.git_detected:
|
|
20
|
+
typer.echo("Git: detected")
|
|
21
|
+
else:
|
|
22
|
+
typer.echo("Git: not detected")
|
|
23
|
+
if project_status.config_exists:
|
|
24
|
+
typer.echo("Configuration: found")
|
|
25
|
+
else:
|
|
26
|
+
typer.echo("Configuration: missing")
|
chronicle/cli/main.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from chronicle.cli.commands.init import init
|
|
3
|
+
from chronicle.cli.commands.version import version
|
|
4
|
+
from chronicle.cli.commands.status import status
|
|
5
|
+
from chronicle.cli.commands.scan import scan
|
|
6
|
+
from chronicle.cli.commands.show import show
|
|
7
|
+
from chronicle.cli.commands.analyze import analyze
|
|
8
|
+
from chronicle.cli.commands.config import config_app
|
|
9
|
+
from chronicle.cli.commands.interpret import interpret
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="chronicle",
|
|
13
|
+
help="Local-first developer intelligence tool.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
app.command(name="init")(init)
|
|
18
|
+
app.command(name="version")(version)
|
|
19
|
+
app.command(name="status")(status)
|
|
20
|
+
app.command(name="scan")(scan)
|
|
21
|
+
app.command(name="show")(show)
|
|
22
|
+
app.command(name="analyze")(analyze)
|
|
23
|
+
app.command(name="interpret")(interpret)
|
|
24
|
+
app.add_typer(
|
|
25
|
+
config_app,
|
|
26
|
+
name="config"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def main():
|
|
30
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import keyring
|
|
3
|
+
|
|
4
|
+
SERVICE_NAME = "chronicle"
|
|
5
|
+
|
|
6
|
+
def set_api_key(provider:str,api_key:str):
|
|
7
|
+
keyring.set_password(
|
|
8
|
+
SERVICE_NAME,
|
|
9
|
+
provider,
|
|
10
|
+
api_key
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def get_api_key(provider: str) -> str | None:
|
|
14
|
+
return keyring.get_password(SERVICE_NAME,provider)
|
|
15
|
+
|
|
16
|
+
def has_api_key(provider: str) -> bool:
|
|
17
|
+
return get_api_key(provider) is not None
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
try:
|
|
3
|
+
import tomllib #type:ignore
|
|
4
|
+
except ModuleNotFoundError:
|
|
5
|
+
import tomli as tomllib
|
|
6
|
+
|
|
7
|
+
CHRONICLE_DIRECTORY = ".chronicle"
|
|
8
|
+
CONFIG_FILE = "config.toml"
|
|
9
|
+
|
|
10
|
+
def get_chronicle_directory(project_root:Path) -> Path:
|
|
11
|
+
return project_root / CHRONICLE_DIRECTORY
|
|
12
|
+
|
|
13
|
+
def get_config_path(project_root:Path) -> Path:
|
|
14
|
+
return get_chronicle_directory(project_root=project_root) / CONFIG_FILE
|
|
15
|
+
|
|
16
|
+
def load_config(config_path: Path) ->dict:
|
|
17
|
+
if not config_path.exists():
|
|
18
|
+
return {}
|
|
19
|
+
|
|
20
|
+
with config_path.open("rb") as file:
|
|
21
|
+
return tomllib.load(file)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from chronicle.config.loader import *
|
|
3
|
+
import tomli_w
|
|
4
|
+
|
|
5
|
+
DEFAULT_CONFIG = """\
|
|
6
|
+
[chronicle]
|
|
7
|
+
version=1
|
|
8
|
+
|
|
9
|
+
[ai]
|
|
10
|
+
provider=""
|
|
11
|
+
model=""
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def create_gitignore(project_root:Path):
|
|
15
|
+
gitignore_path = project_root / ".gitignore"
|
|
16
|
+
if not gitignore_path.exists():
|
|
17
|
+
gitignore_path.write_text(".chronicle/\n")
|
|
18
|
+
return
|
|
19
|
+
existing_content = gitignore_path.read_text()
|
|
20
|
+
if ".chronicle/" in existing_content.splitlines():
|
|
21
|
+
return
|
|
22
|
+
with gitignore_path.open(mode="a") as file:
|
|
23
|
+
file.write("\n.chronicle/\n")
|
|
24
|
+
|
|
25
|
+
def create_config(config_path:Path) -> bool:
|
|
26
|
+
if not config_path.exists():
|
|
27
|
+
config_path.write_text(
|
|
28
|
+
DEFAULT_CONFIG
|
|
29
|
+
)
|
|
30
|
+
return True
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def update_ai_config(
|
|
34
|
+
config_path:Path,
|
|
35
|
+
provider:str | None = None,
|
|
36
|
+
model:str | None=None,
|
|
37
|
+
) -> None:
|
|
38
|
+
if not config_path.exists():
|
|
39
|
+
create_config(config_path)
|
|
40
|
+
|
|
41
|
+
config = load_config(config_path)
|
|
42
|
+
|
|
43
|
+
ai = config.setdefault("ai",{})
|
|
44
|
+
if provider is not None:
|
|
45
|
+
ai["provider"] = provider
|
|
46
|
+
if model is not None:
|
|
47
|
+
ai["model"] = model
|
|
48
|
+
|
|
49
|
+
with config_path.open("wb") as file:
|
|
50
|
+
tomli_w.dump(config,file)
|
|
51
|
+
|
|
52
|
+
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
class GitIntegration:
|
|
5
|
+
def __init__(self,repo:Path) -> None:
|
|
6
|
+
self.repo = repo
|
|
7
|
+
|
|
8
|
+
def run(self, *arguments:str) -> str:
|
|
9
|
+
result = subprocess.run(
|
|
10
|
+
["git", *arguments],
|
|
11
|
+
cwd=self.repo,
|
|
12
|
+
capture_output=True,
|
|
13
|
+
check=True,
|
|
14
|
+
text=True,
|
|
15
|
+
)
|
|
16
|
+
return result.stdout
|
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from chronicle.interpretation.context import InterpretationContext
|
|
2
|
+
from chronicle.storage.models import Findings,Observation
|
|
3
|
+
from chronicle.project.discovery import find_project_root
|
|
4
|
+
from nltk.tokenize import word_tokenize
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
STOP_WORDS = {"the","a","an","is","was","were","to","of","in","on","for","my","me","why","what","how","did","does","do","has","have","been","there",}
|
|
8
|
+
|
|
9
|
+
def build_context(
|
|
10
|
+
question:str,
|
|
11
|
+
findings: list[Findings],
|
|
12
|
+
observations:list[Observation],
|
|
13
|
+
) -> InterpretationContext:
|
|
14
|
+
|
|
15
|
+
keywords = _extract_keywords(question)
|
|
16
|
+
scored_findings = []
|
|
17
|
+
|
|
18
|
+
for finding in findings:
|
|
19
|
+
score = _score_finding(
|
|
20
|
+
finding,
|
|
21
|
+
keywords,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if score > 0:
|
|
25
|
+
scored_findings.append(
|
|
26
|
+
(score, finding)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
scored_findings.sort(
|
|
30
|
+
key = lambda item: item[0],
|
|
31
|
+
reverse=True
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
selected_findings = [
|
|
35
|
+
finding for _, finding in scored_findings[:10]
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
observation_map = {
|
|
39
|
+
observation.id: observation for observation in observations
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
selected_observations = []
|
|
43
|
+
|
|
44
|
+
for finding in selected_findings:
|
|
45
|
+
observation = observation_map.get(finding.observation_id)
|
|
46
|
+
|
|
47
|
+
if observation is not None:
|
|
48
|
+
selected_observations.append(observation)
|
|
49
|
+
|
|
50
|
+
related_observations = []
|
|
51
|
+
|
|
52
|
+
for observation in selected_observations:
|
|
53
|
+
related = _find_related_git_observations(
|
|
54
|
+
observation,
|
|
55
|
+
observations
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
related_observations.extend(related)
|
|
59
|
+
|
|
60
|
+
selected_observations.extend(related_observations)
|
|
61
|
+
|
|
62
|
+
unique_observations = {}
|
|
63
|
+
|
|
64
|
+
for observation in selected_observations:
|
|
65
|
+
unique_observations[observation.id] = observation
|
|
66
|
+
|
|
67
|
+
selected_observations = list(unique_observations.values())
|
|
68
|
+
|
|
69
|
+
return InterpretationContext(
|
|
70
|
+
question=question,
|
|
71
|
+
findings=selected_findings,
|
|
72
|
+
observations=selected_observations,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _extract_keywords(question:str) -> list[str]:
|
|
76
|
+
tokens = word_tokenize(question.lower())
|
|
77
|
+
return [
|
|
78
|
+
token for token in tokens if token.isalnum() and token not in STOP_WORDS and len(token) >= 3
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def _finding_text(finding: Findings) -> str:
|
|
82
|
+
return " ".join(
|
|
83
|
+
[
|
|
84
|
+
finding.title,
|
|
85
|
+
finding.message,
|
|
86
|
+
str(finding.data),
|
|
87
|
+
]
|
|
88
|
+
).lower()
|
|
89
|
+
|
|
90
|
+
def _score_finding(finding: Findings, keywords: list[str]) -> int:
|
|
91
|
+
text = _finding_text(finding)
|
|
92
|
+
score = 0
|
|
93
|
+
|
|
94
|
+
for keyword in keywords:
|
|
95
|
+
if keyword in finding.title.lower():
|
|
96
|
+
score += 3
|
|
97
|
+
if keyword in finding.message.lower():
|
|
98
|
+
score += 2
|
|
99
|
+
if keyword in str(finding.data).lower():
|
|
100
|
+
score += 3
|
|
101
|
+
|
|
102
|
+
return score
|
|
103
|
+
|
|
104
|
+
def _get_migration_path(observation: Observation) -> Path | None:
|
|
105
|
+
project_root = find_project_root()
|
|
106
|
+
if project_root is None:
|
|
107
|
+
return None
|
|
108
|
+
if observation.source != "django":
|
|
109
|
+
return None
|
|
110
|
+
if observation.type != "migration":
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
root_name = project_root.name
|
|
114
|
+
migration_name = observation.data.get("name")
|
|
115
|
+
app_name = observation.data.get("app")
|
|
116
|
+
if not migration_name:
|
|
117
|
+
return None
|
|
118
|
+
if not app_name:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
migration_file = migration_name + ".py"
|
|
122
|
+
if app_name == root_name:
|
|
123
|
+
return Path("migrations") / migration_file
|
|
124
|
+
|
|
125
|
+
return Path(app_name) / "migrations" / migration_file
|
|
126
|
+
|
|
127
|
+
def _find_related_git_observations(migration: Observation, observations: list[Observation]) -> list[Observation]:
|
|
128
|
+
migration_path = _get_migration_path(migration)
|
|
129
|
+
|
|
130
|
+
if migration_path is None:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
related = []
|
|
134
|
+
for observation in observations:
|
|
135
|
+
if observation.source != "git":
|
|
136
|
+
continue
|
|
137
|
+
if observation.type != "commit":
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
changes = observation.data.get("changes",[])
|
|
141
|
+
|
|
142
|
+
for change in changes:
|
|
143
|
+
if change.get("path") == str(migration_path):
|
|
144
|
+
related.append(observation)
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
return related
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from chronicle.ai.base import AIProvider
|
|
2
|
+
from chronicle.interpretation.context import InterpretationContext
|
|
3
|
+
from chronicle.interpretation.prompts import build_interpretation_prompt
|
|
4
|
+
|
|
5
|
+
class Interpreter:
|
|
6
|
+
def __init__(self,provider: AIProvider) -> None:
|
|
7
|
+
self.provider = provider
|
|
8
|
+
|
|
9
|
+
def interpret(self, context: InterpretationContext) -> str:
|
|
10
|
+
prompt = build_interpretation_prompt(context)
|
|
11
|
+
|
|
12
|
+
return self.provider.generate(prompt)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from chronicle.interpretation.context import InterpretationContext
|
|
2
|
+
|
|
3
|
+
def build_interpretation_prompt(context: InterpretationContext) -> str:
|
|
4
|
+
prompt = f"""
|
|
5
|
+
You are Chronicle, a software project history interpreter.
|
|
6
|
+
|
|
7
|
+
Answer the user's question using only the project history
|
|
8
|
+
provided below.
|
|
9
|
+
|
|
10
|
+
User question:
|
|
11
|
+
{context.question}
|
|
12
|
+
|
|
13
|
+
Findings:
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
for finding in context.findings:
|
|
17
|
+
prompt += f"""
|
|
18
|
+
- Analyzer: {finding.analyzer}
|
|
19
|
+
- Severity: {finding.severity}
|
|
20
|
+
- Title: {finding.title}
|
|
21
|
+
- Message: {finding.message}
|
|
22
|
+
- Observation ID: {finding.observation_id}
|
|
23
|
+
- Data: {finding.data}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
prompt += """
|
|
27
|
+
|
|
28
|
+
Observations:
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
for observation in context.observations:
|
|
32
|
+
prompt += f"""
|
|
33
|
+
- ID: {observation.id}
|
|
34
|
+
- Source: {observation.source}
|
|
35
|
+
- Type: {observation.type}
|
|
36
|
+
- External ID: {observation.external_id}
|
|
37
|
+
- Timestamp: {observation.timestamp}
|
|
38
|
+
- Data: {observation.data}
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
prompt += """
|
|
42
|
+
|
|
43
|
+
Explain what happened, why it happened, and what effect it
|
|
44
|
+
may have on the project.
|
|
45
|
+
|
|
46
|
+
If the provided project history does not contain enough
|
|
47
|
+
information to answer the question, say so instead of
|
|
48
|
+
inventing information.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
return prompt
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
def find_project_root(start:Path | None=None) -> Path | None:
|
|
4
|
+
"""Find the nearest ancestor containing a .git directory"""
|
|
5
|
+
cwd = start or Path.cwd()
|
|
6
|
+
for directory in [cwd,*cwd.parents]:
|
|
7
|
+
if (directory / ".git").exists():
|
|
8
|
+
return directory
|
|
9
|
+
return None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from chronicle.config.loader import *
|
|
3
|
+
from chronicle.config.manager import *
|
|
4
|
+
from chronicle.storage.schema import init_schema
|
|
5
|
+
from chronicle.storage.database import connect
|
|
6
|
+
from chronicle.config.credentials import has_api_key,set_api_key
|
|
7
|
+
import nltk
|
|
8
|
+
import os
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
def initialize_chronicle(project_root:Path) -> tuple[Path, bool]:
|
|
12
|
+
chronicle_directory = get_chronicle_directory(project_root)
|
|
13
|
+
chronicle_directory.mkdir(parents=True,exist_ok=True)
|
|
14
|
+
|
|
15
|
+
db_path = chronicle_directory / "chronicle.db"
|
|
16
|
+
if not db_path.exists():
|
|
17
|
+
db_path.touch()
|
|
18
|
+
|
|
19
|
+
conn = connect(db_path)
|
|
20
|
+
init_schema(conn)
|
|
21
|
+
|
|
22
|
+
config_path = get_config_path(project_root)
|
|
23
|
+
created = create_config(config_path)
|
|
24
|
+
|
|
25
|
+
create_gitignore(project_root)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
nltk.data.find("tokenizers/punkt_tab")
|
|
29
|
+
except LookupError:
|
|
30
|
+
nltk.download("punkt_tab",quiet=True)
|
|
31
|
+
|
|
32
|
+
return config_path,created
|
|
33
|
+
|
|
34
|
+
def setup_ai_config(config_path: Path):
|
|
35
|
+
provider = typer.prompt(" AI provider", default="gemini")
|
|
36
|
+
model = typer.prompt(" AI model", default="gemini-2.5-flash")
|
|
37
|
+
if has_api_key(provider):
|
|
38
|
+
typer.echo(" API key already found in the system.")
|
|
39
|
+
else:
|
|
40
|
+
api_key = typer.prompt(" API KEY",hide_input=True)
|
|
41
|
+
set_api_key(provider,api_key)
|
|
42
|
+
typer.echo(" ✓ API key stored securely.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
update_ai_config(config_path=config_path,provider=provider,model=model)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from chronicle.project.discovery import find_project_root
|
|
4
|
+
from chronicle.config.loader import *
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ProjectStatus:
|
|
8
|
+
project_root:Path
|
|
9
|
+
chronicle_initialized:bool
|
|
10
|
+
git_detected:bool
|
|
11
|
+
config_exists:bool
|
|
12
|
+
|
|
13
|
+
def get_status() -> ProjectStatus | None:
|
|
14
|
+
project_root = find_project_root()
|
|
15
|
+
if project_root is None:
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
config_path = get_config_path(project_root)
|
|
19
|
+
|
|
20
|
+
return ProjectStatus(
|
|
21
|
+
project_root=project_root,
|
|
22
|
+
chronicle_initialized=config_path.exists(),
|
|
23
|
+
git_detected=(project_root / ".git").exists(),
|
|
24
|
+
config_exists=config_path.exists()
|
|
25
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from chronicle.scanning.scanners.base import Scanner
|
|
3
|
+
from chronicle.storage.models import Observation
|
|
4
|
+
from .context import ScanContext
|
|
5
|
+
|
|
6
|
+
class ScanEngine:
|
|
7
|
+
def __init__(self,project_root:Path,scanners:list[Scanner]) -> None:
|
|
8
|
+
self.project_root = project_root
|
|
9
|
+
self.scanners = scanners
|
|
10
|
+
|
|
11
|
+
def scan(self,contexts:list[ScanContext]) -> list[Observation]:
|
|
12
|
+
observation:list[Observation] = []
|
|
13
|
+
for scanner in self.scanners:
|
|
14
|
+
observation.extend(scanner.scan(contexts=contexts))
|
|
15
|
+
|
|
16
|
+
return observation
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ...storage.models import Observation
|
|
5
|
+
from chronicle.scanning.context import ScanContext
|
|
6
|
+
|
|
7
|
+
class Scanner(ABC):
|
|
8
|
+
"""Base class for all chronicle scanners."""
|
|
9
|
+
|
|
10
|
+
def __init__(self,project_root:Path) -> None:
|
|
11
|
+
self.project_root = project_root
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def scan(self,contexts:list[ScanContext]) -> list[Observation]:
|
|
15
|
+
"""Scan the project and return observation"""
|
|
16
|
+
pass
|
|
17
|
+
|