auditview 0.1.0__tar.gz
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.
- auditview-0.1.0/PKG-INFO +11 -0
- auditview-0.1.0/README.md +0 -0
- auditview-0.1.0/auditview/__init__.py +0 -0
- auditview-0.1.0/auditview/__main__.py +29 -0
- auditview-0.1.0/auditview/api/__init__.py +0 -0
- auditview-0.1.0/auditview/api/checkpoints.py +59 -0
- auditview-0.1.0/auditview/api/config.py +11 -0
- auditview-0.1.0/auditview/api/coverage.py +48 -0
- auditview-0.1.0/auditview/api/events.py +34 -0
- auditview-0.1.0/auditview/api/files.py +207 -0
- auditview-0.1.0/auditview/api/lines.py +63 -0
- auditview-0.1.0/auditview/api/notes.py +185 -0
- auditview-0.1.0/auditview/api/sessions.py +57 -0
- auditview-0.1.0/auditview/api/util.py +10 -0
- auditview-0.1.0/auditview/app.py +52 -0
- auditview-0.1.0/auditview/core/__init__.py +0 -0
- auditview-0.1.0/auditview/core/coverage.py +32 -0
- auditview-0.1.0/auditview/core/hashing.py +10 -0
- auditview-0.1.0/auditview/core/reconciler.py +192 -0
- auditview-0.1.0/auditview/core/scanner.py +86 -0
- auditview-0.1.0/auditview/core/watcher.py +95 -0
- auditview-0.1.0/auditview/db/__init__.py +0 -0
- auditview-0.1.0/auditview/db/checkpoint.py +80 -0
- auditview-0.1.0/auditview/db/connection.py +92 -0
- auditview-0.1.0/auditview/db/schema.py +86 -0
- auditview-0.1.0/auditview/static/assets/index-BujY485r.js +13 -0
- auditview-0.1.0/auditview/static/assets/index-hyKrPdV5.css +1 -0
- auditview-0.1.0/auditview/static/index.html +14 -0
- auditview-0.1.0/auditview.egg-info/PKG-INFO +11 -0
- auditview-0.1.0/auditview.egg-info/SOURCES.txt +35 -0
- auditview-0.1.0/auditview.egg-info/dependency_links.txt +1 -0
- auditview-0.1.0/auditview.egg-info/entry_points.txt +2 -0
- auditview-0.1.0/auditview.egg-info/requires.txt +5 -0
- auditview-0.1.0/auditview.egg-info/top_level.txt +1 -0
- auditview-0.1.0/pyproject.toml +26 -0
- auditview-0.1.0/setup.cfg +4 -0
- auditview-0.1.0/setup.py +70 -0
auditview-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auditview
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Code review/audit tool
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: flask>=3.1
|
|
8
|
+
Requires-Dist: waitress>=3.0
|
|
9
|
+
Requires-Dist: watchdog>=6.0
|
|
10
|
+
Requires-Dist: pathspec>=0.12
|
|
11
|
+
Requires-Dist: apsw>=3.49
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from waitress import serve
|
|
5
|
+
from auditview.app import create_app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
p = argparse.ArgumentParser(description="auditview — line-level code review tool")
|
|
10
|
+
p.add_argument("path", nargs="?", default=".",
|
|
11
|
+
help="Root folder to audit (default: current directory)")
|
|
12
|
+
p.add_argument("--db", metavar="FILE",
|
|
13
|
+
help="SQLite database file path (default: <root>/.auditview.db)")
|
|
14
|
+
p.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
|
|
15
|
+
p.add_argument("--port", type=int, default=5000, help="Bind port (default: 5000)")
|
|
16
|
+
args = p.parse_args()
|
|
17
|
+
|
|
18
|
+
root = os.path.abspath(args.path)
|
|
19
|
+
db_path = os.path.abspath(args.db) if args.db else os.path.join(root, ".auditview.db")
|
|
20
|
+
|
|
21
|
+
app = create_app(db_path, root)
|
|
22
|
+
print(f"auditview root={root}")
|
|
23
|
+
print(f" db={db_path}")
|
|
24
|
+
print(f" http://{args.host}:{args.port}")
|
|
25
|
+
serve(app, host=args.host, port=args.port)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from flask import Blueprint, jsonify, current_app
|
|
2
|
+
from auditview.db.connection import open_db
|
|
3
|
+
from auditview.db.checkpoint import CheckpointManager
|
|
4
|
+
|
|
5
|
+
bp = Blueprint("checkpoints", __name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@bp.route("/sessions/<int:session_id>/checkpoints", methods=["GET"])
|
|
9
|
+
def list_checkpoints(session_id):
|
|
10
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
11
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
12
|
+
cur = conn.cursor()
|
|
13
|
+
|
|
14
|
+
row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
15
|
+
if row is None:
|
|
16
|
+
return jsonify({"error": "Session not found"}), 404
|
|
17
|
+
|
|
18
|
+
rows = cur.execute(
|
|
19
|
+
"SELECT id, label, created_at FROM checkpoints WHERE session_id = ? ORDER BY id DESC",
|
|
20
|
+
(session_id,),
|
|
21
|
+
).fetchall()
|
|
22
|
+
|
|
23
|
+
def to_dict(r):
|
|
24
|
+
if is_apsw:
|
|
25
|
+
return {"id": r[0], "label": r[1], "created_at": r[2]}
|
|
26
|
+
return {"id": r["id"], "label": r["label"], "created_at": r["created_at"]}
|
|
27
|
+
|
|
28
|
+
return jsonify([to_dict(r) for r in rows])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@bp.route("/sessions/<int:session_id>/checkpoints/<int:checkpoint_id>/revert", methods=["POST"])
|
|
32
|
+
def revert_checkpoint(session_id, checkpoint_id):
|
|
33
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
34
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
35
|
+
cur = conn.cursor()
|
|
36
|
+
|
|
37
|
+
row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
38
|
+
if row is None:
|
|
39
|
+
return jsonify({"error": "Session not found"}), 404
|
|
40
|
+
|
|
41
|
+
cm = CheckpointManager(conn)
|
|
42
|
+
if not cm.supports_checkpoints():
|
|
43
|
+
return jsonify({"error": "Checkpoints not supported: apsw not available"}), 409
|
|
44
|
+
|
|
45
|
+
cp_row = cur.execute(
|
|
46
|
+
"SELECT id FROM checkpoints WHERE id = ? AND session_id = ?",
|
|
47
|
+
(checkpoint_id, session_id),
|
|
48
|
+
).fetchone()
|
|
49
|
+
if cp_row is None:
|
|
50
|
+
return jsonify({"error": "Checkpoint not found"}), 404
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
cm.revert(checkpoint_id)
|
|
54
|
+
except KeyError as e:
|
|
55
|
+
return jsonify({"error": str(e)}), 404
|
|
56
|
+
except Exception as e:
|
|
57
|
+
return jsonify({"error": f"Revert failed: {e}"}), 500
|
|
58
|
+
|
|
59
|
+
return jsonify({"reverted": True})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from flask import Blueprint, jsonify, current_app
|
|
2
|
+
|
|
3
|
+
bp = Blueprint("config", __name__)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@bp.route("/config", methods=["GET"])
|
|
7
|
+
def get_config():
|
|
8
|
+
return jsonify({
|
|
9
|
+
"root_path": current_app.config["ROOT_PATH"],
|
|
10
|
+
"db_path": current_app.config["DB_PATH"],
|
|
11
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from flask import Blueprint, jsonify, current_app
|
|
2
|
+
from auditview.db.connection import open_db
|
|
3
|
+
from auditview.db.checkpoint import CheckpointManager
|
|
4
|
+
|
|
5
|
+
bp = Blueprint("coverage", __name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@bp.route("/sessions/<int:session_id>/coverage", methods=["GET"])
|
|
9
|
+
def get_coverage(session_id):
|
|
10
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
11
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
12
|
+
cur = conn.cursor()
|
|
13
|
+
|
|
14
|
+
row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
15
|
+
if row is None:
|
|
16
|
+
return jsonify({"error": "Session not found"}), 404
|
|
17
|
+
|
|
18
|
+
# Aggregate using cached countable_lines and indexed reviewed_lines count
|
|
19
|
+
agg = cur.execute(
|
|
20
|
+
"""
|
|
21
|
+
SELECT
|
|
22
|
+
COUNT(f.id) AS total_files,
|
|
23
|
+
COALESCE(SUM(f.countable_lines), 0) AS total_countable,
|
|
24
|
+
COUNT(DISTINCT rl.file_path || '|' || rl.line_hash || '|' || rl.context_hash)
|
|
25
|
+
AS total_reviewed
|
|
26
|
+
FROM files f
|
|
27
|
+
LEFT JOIN reviewed_lines rl
|
|
28
|
+
ON rl.session_id = f.session_id AND rl.file_path = f.rel_path
|
|
29
|
+
WHERE f.session_id = ? AND f.countable_lines IS NOT NULL
|
|
30
|
+
""",
|
|
31
|
+
(session_id,),
|
|
32
|
+
).fetchone()
|
|
33
|
+
|
|
34
|
+
total_files = agg[0] if is_apsw else agg["total_files"]
|
|
35
|
+
total_countable = agg[1] if is_apsw else agg["total_countable"]
|
|
36
|
+
total_reviewed = agg[2] if is_apsw else agg["total_reviewed"]
|
|
37
|
+
|
|
38
|
+
coverage = total_reviewed / total_countable if total_countable > 0 else 0.0
|
|
39
|
+
|
|
40
|
+
cm = CheckpointManager(conn)
|
|
41
|
+
|
|
42
|
+
return jsonify({
|
|
43
|
+
"total_files": total_files,
|
|
44
|
+
"total_countable_lines": total_countable,
|
|
45
|
+
"total_reviewed_lines": total_reviewed,
|
|
46
|
+
"coverage": coverage,
|
|
47
|
+
"supports_checkpoints": cm.supports_checkpoints(),
|
|
48
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import queue
|
|
3
|
+
|
|
4
|
+
from flask import Blueprint, jsonify, current_app, Response
|
|
5
|
+
|
|
6
|
+
from auditview.db.connection import open_db
|
|
7
|
+
|
|
8
|
+
bp = Blueprint("events", __name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@bp.route("/sessions/<int:session_id>/events", methods=["GET"])
|
|
12
|
+
def event_stream(session_id):
|
|
13
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
14
|
+
cur = conn.cursor()
|
|
15
|
+
row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
16
|
+
conn.close()
|
|
17
|
+
if row is None:
|
|
18
|
+
return jsonify({"error": "Session not found"}), 404
|
|
19
|
+
|
|
20
|
+
watcher = current_app.watcher
|
|
21
|
+
q = watcher.register_client(session_id)
|
|
22
|
+
|
|
23
|
+
def generate():
|
|
24
|
+
try:
|
|
25
|
+
while True:
|
|
26
|
+
try:
|
|
27
|
+
event = q.get(timeout=15)
|
|
28
|
+
yield f"event: {event['type']}\ndata: {json.dumps({k: v for k, v in event.items() if k != 'type'})}\n\n"
|
|
29
|
+
except queue.Empty:
|
|
30
|
+
yield "event: heartbeat\ndata: {}\n\n"
|
|
31
|
+
finally:
|
|
32
|
+
watcher.unregister_client(session_id, q)
|
|
33
|
+
|
|
34
|
+
return Response(generate(), content_type="text/event-stream")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from flask import Blueprint, jsonify, request, current_app
|
|
3
|
+
from auditview.db.connection import open_db
|
|
4
|
+
from auditview.core.hashing import line_hash, context_hash
|
|
5
|
+
from auditview.core.coverage import is_countable_line
|
|
6
|
+
from auditview.core.reconciler import reconcile_file
|
|
7
|
+
from auditview.core.scanner import scan_folder
|
|
8
|
+
from auditview.api.util import safe_path
|
|
9
|
+
|
|
10
|
+
bp = Blueprint("files", __name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_session(cur, session_id, is_apsw):
|
|
14
|
+
row = cur.execute(
|
|
15
|
+
"SELECT id, root_path, exclusion_patterns FROM sessions WHERE id = ?",
|
|
16
|
+
(session_id,),
|
|
17
|
+
).fetchone()
|
|
18
|
+
if row is None:
|
|
19
|
+
return None
|
|
20
|
+
if is_apsw:
|
|
21
|
+
return {"id": row[0], "root_path": row[1], "exclusion_patterns": row[2]}
|
|
22
|
+
return dict(row)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@bp.route("/sessions/<int:session_id>/files", methods=["GET"])
|
|
26
|
+
def list_files(session_id):
|
|
27
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
28
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
29
|
+
cur = conn.cursor()
|
|
30
|
+
|
|
31
|
+
session = _get_session(cur, session_id, is_apsw)
|
|
32
|
+
if session is None:
|
|
33
|
+
return jsonify({"error": "Session not found"}), 404
|
|
34
|
+
|
|
35
|
+
root_path = session["root_path"]
|
|
36
|
+
exclusion_patterns = session["exclusion_patterns"]
|
|
37
|
+
|
|
38
|
+
rel_paths = scan_folder(root_path, exclusion_patterns)
|
|
39
|
+
rel_path_set = set(rel_paths)
|
|
40
|
+
|
|
41
|
+
for rel_path in rel_paths:
|
|
42
|
+
cur.execute(
|
|
43
|
+
"INSERT INTO files (session_id, rel_path) VALUES (?, ?) ON CONFLICT DO NOTHING",
|
|
44
|
+
(session_id, rel_path),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Fetch cached countable_lines per file
|
|
48
|
+
file_rows = cur.execute(
|
|
49
|
+
"SELECT rel_path, countable_lines FROM files WHERE session_id = ?",
|
|
50
|
+
(session_id,),
|
|
51
|
+
).fetchall()
|
|
52
|
+
countable_map = {}
|
|
53
|
+
uncached = []
|
|
54
|
+
for r in file_rows:
|
|
55
|
+
rp = r[0] if is_apsw else r["rel_path"]
|
|
56
|
+
cl = r[1] if is_apsw else r["countable_lines"]
|
|
57
|
+
countable_map[rp] = cl
|
|
58
|
+
if cl is None and rp in rel_path_set:
|
|
59
|
+
uncached.append(rp)
|
|
60
|
+
|
|
61
|
+
# Compute and cache for files not yet indexed
|
|
62
|
+
for rp in uncached:
|
|
63
|
+
ext = os.path.splitext(rp)[1].lower()
|
|
64
|
+
full_path = os.path.join(root_path, rp)
|
|
65
|
+
countable = 0
|
|
66
|
+
if os.path.isfile(full_path):
|
|
67
|
+
try:
|
|
68
|
+
with open(full_path, "r", encoding="utf-8", errors="replace") as f:
|
|
69
|
+
countable = sum(1 for l in f.read().splitlines() if is_countable_line(l, ext))
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
cur.execute(
|
|
73
|
+
"UPDATE files SET countable_lines = ? WHERE session_id = ? AND rel_path = ?",
|
|
74
|
+
(countable, session_id, rp),
|
|
75
|
+
)
|
|
76
|
+
countable_map[rp] = countable
|
|
77
|
+
|
|
78
|
+
# Batch: reviewed counts per file
|
|
79
|
+
reviewed_rows = cur.execute(
|
|
80
|
+
"SELECT file_path, COUNT(*) FROM reviewed_lines WHERE session_id = ? GROUP BY file_path",
|
|
81
|
+
(session_id,),
|
|
82
|
+
).fetchall()
|
|
83
|
+
reviewed_map = {(r[0] if is_apsw else r["file_path"]): (r[1] if is_apsw else r[1])
|
|
84
|
+
for r in reviewed_rows}
|
|
85
|
+
|
|
86
|
+
# Batch: note/todo counts per file (live only)
|
|
87
|
+
note_rows = cur.execute(
|
|
88
|
+
"SELECT file_path, "
|
|
89
|
+
"SUM(CASE WHEN is_todo=0 THEN 1 ELSE 0 END), "
|
|
90
|
+
"SUM(CASE WHEN is_todo=1 THEN 1 ELSE 0 END) "
|
|
91
|
+
"FROM notes WHERE session_id = ? AND is_orphaned=0 GROUP BY file_path",
|
|
92
|
+
(session_id,),
|
|
93
|
+
).fetchall()
|
|
94
|
+
notes_map = {(r[0] if is_apsw else r["file_path"]): (
|
|
95
|
+
(r[1] if is_apsw else r[1]) or 0,
|
|
96
|
+
(r[2] if is_apsw else r[2]) or 0,
|
|
97
|
+
) for r in note_rows}
|
|
98
|
+
|
|
99
|
+
result = []
|
|
100
|
+
for rel_path in rel_paths:
|
|
101
|
+
countable = countable_map.get(rel_path) or 0
|
|
102
|
+
reviewed = reviewed_map.get(rel_path, 0)
|
|
103
|
+
coverage = reviewed / countable if countable > 0 else 0.0
|
|
104
|
+
notes_c, todos_c = notes_map.get(rel_path, (0, 0))
|
|
105
|
+
|
|
106
|
+
if countable == 0:
|
|
107
|
+
status = "empty"
|
|
108
|
+
elif reviewed == 0:
|
|
109
|
+
status = "not_viewed"
|
|
110
|
+
elif reviewed >= countable:
|
|
111
|
+
status = "reviewed"
|
|
112
|
+
else:
|
|
113
|
+
status = "partial"
|
|
114
|
+
|
|
115
|
+
result.append({
|
|
116
|
+
"rel_path": rel_path,
|
|
117
|
+
"countable_lines": countable,
|
|
118
|
+
"reviewed_lines": reviewed,
|
|
119
|
+
"coverage": coverage,
|
|
120
|
+
"status": status,
|
|
121
|
+
"notes_count": notes_c,
|
|
122
|
+
"todos_count": todos_c,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return jsonify(result)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@bp.route("/sessions/<int:session_id>/files/<path:fpath>", methods=["GET"])
|
|
129
|
+
def get_file(session_id, fpath):
|
|
130
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
131
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
132
|
+
cur = conn.cursor()
|
|
133
|
+
|
|
134
|
+
session = _get_session(cur, session_id, is_apsw)
|
|
135
|
+
if session is None:
|
|
136
|
+
return jsonify({"error": "Session not found"}), 404
|
|
137
|
+
|
|
138
|
+
root_path = session["root_path"]
|
|
139
|
+
full_path = safe_path(root_path, fpath)
|
|
140
|
+
if full_path is None:
|
|
141
|
+
return jsonify({"error": "Invalid path"}), 400
|
|
142
|
+
|
|
143
|
+
if not os.path.isfile(full_path):
|
|
144
|
+
return jsonify({"error": "File not found"}), 404
|
|
145
|
+
|
|
146
|
+
file_row = cur.execute(
|
|
147
|
+
"SELECT last_mtime FROM files WHERE session_id = ? AND rel_path = ?",
|
|
148
|
+
(session_id, fpath),
|
|
149
|
+
).fetchone()
|
|
150
|
+
if file_row is None:
|
|
151
|
+
return jsonify({"error": "File not tracked in this session — call list_files first"}), 404
|
|
152
|
+
|
|
153
|
+
current_mtime = os.path.getmtime(full_path)
|
|
154
|
+
stored_mtime = file_row[0] if is_apsw else file_row["last_mtime"]
|
|
155
|
+
if stored_mtime is None or abs(stored_mtime - current_mtime) > 1e-6:
|
|
156
|
+
reconcile_file(conn, session_id, fpath, root_path)
|
|
157
|
+
|
|
158
|
+
skip_comments = request.args.get("skip_comments", "1") != "0"
|
|
159
|
+
ext = os.path.splitext(fpath)[1].lower()
|
|
160
|
+
|
|
161
|
+
with open(full_path, "r", encoding="utf-8", errors="replace") as f:
|
|
162
|
+
content = f.read()
|
|
163
|
+
lines = content.splitlines()
|
|
164
|
+
|
|
165
|
+
reviewed_rows = cur.execute(
|
|
166
|
+
"SELECT line_hash, context_hash FROM reviewed_lines "
|
|
167
|
+
"WHERE session_id = ? AND file_path = ?",
|
|
168
|
+
(session_id, fpath),
|
|
169
|
+
).fetchall()
|
|
170
|
+
|
|
171
|
+
if is_apsw:
|
|
172
|
+
reviewed_set = {(r[0], r[1]) for r in reviewed_rows}
|
|
173
|
+
else:
|
|
174
|
+
reviewed_set = {(r["line_hash"], r["context_hash"]) for r in reviewed_rows}
|
|
175
|
+
|
|
176
|
+
result_lines = []
|
|
177
|
+
for i, line_content in enumerate(lines):
|
|
178
|
+
prev_content = lines[i - 1] if i > 0 else ""
|
|
179
|
+
next_content = lines[i + 1] if i < len(lines) - 1 else ""
|
|
180
|
+
lh = line_hash(line_content)
|
|
181
|
+
ch = context_hash(prev_content, line_content, next_content)
|
|
182
|
+
result_lines.append({
|
|
183
|
+
"line_no": i + 1,
|
|
184
|
+
"content": line_content,
|
|
185
|
+
"line_hash": lh,
|
|
186
|
+
"context_hash": ch,
|
|
187
|
+
"is_reviewed": (lh, ch) in reviewed_set,
|
|
188
|
+
"is_countable": is_countable_line(line_content, ext, skip_comments),
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
note_rows = cur.execute(
|
|
192
|
+
"SELECT id, start_line, end_line, content, is_todo, is_orphaned, snapshot_text, created_at "
|
|
193
|
+
"FROM notes WHERE session_id = ? AND file_path = ? ORDER BY start_line",
|
|
194
|
+
(session_id, fpath),
|
|
195
|
+
).fetchall()
|
|
196
|
+
|
|
197
|
+
def note_to_dict(r):
|
|
198
|
+
if is_apsw:
|
|
199
|
+
return {"id": r[0], "start_line": r[1], "end_line": r[2], "content": r[3],
|
|
200
|
+
"is_todo": bool(r[4]), "is_orphaned": bool(r[5]),
|
|
201
|
+
"snapshot_text": r[6], "created_at": r[7]}
|
|
202
|
+
return {"id": r["id"], "start_line": r["start_line"], "end_line": r["end_line"],
|
|
203
|
+
"content": r["content"], "is_todo": bool(r["is_todo"]),
|
|
204
|
+
"is_orphaned": bool(r["is_orphaned"]),
|
|
205
|
+
"snapshot_text": r["snapshot_text"], "created_at": r["created_at"]}
|
|
206
|
+
|
|
207
|
+
return jsonify({"lines": result_lines, "notes": [note_to_dict(r) for r in note_rows]})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from flask import Blueprint, request, jsonify, current_app
|
|
2
|
+
from auditview.db.connection import open_db
|
|
3
|
+
from auditview.api.util import safe_path
|
|
4
|
+
|
|
5
|
+
bp = Blueprint("lines", __name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@bp.route("/sessions/<int:session_id>/lines/mark", methods=["POST"])
|
|
9
|
+
def mark_lines(session_id):
|
|
10
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
11
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
12
|
+
cur = conn.cursor()
|
|
13
|
+
|
|
14
|
+
row = cur.execute(
|
|
15
|
+
"SELECT id, root_path FROM sessions WHERE id = ?", (session_id,)
|
|
16
|
+
).fetchone()
|
|
17
|
+
if row is None:
|
|
18
|
+
return jsonify({"error": "Session not found"}), 404
|
|
19
|
+
root_path = row[1] if is_apsw else row["root_path"]
|
|
20
|
+
|
|
21
|
+
data = request.get_json(force=True, silent=True) or {}
|
|
22
|
+
file_path = data.get("file_path")
|
|
23
|
+
lines = data.get("lines")
|
|
24
|
+
reviewed = data.get("reviewed")
|
|
25
|
+
|
|
26
|
+
if not file_path or lines is None or reviewed is None:
|
|
27
|
+
return jsonify({"error": "file_path, lines, and reviewed are required"}), 400
|
|
28
|
+
|
|
29
|
+
if not isinstance(lines, list):
|
|
30
|
+
return jsonify({"error": "lines must be an array"}), 400
|
|
31
|
+
|
|
32
|
+
if safe_path(root_path, file_path) is None:
|
|
33
|
+
return jsonify({"error": "Invalid path"}), 400
|
|
34
|
+
|
|
35
|
+
count = 0
|
|
36
|
+
conn.begin()
|
|
37
|
+
try:
|
|
38
|
+
for line in lines:
|
|
39
|
+
lh = line.get("line_hash")
|
|
40
|
+
ch = line.get("context_hash")
|
|
41
|
+
ln = line.get("line_no")
|
|
42
|
+
if not lh or not ch or ln is None:
|
|
43
|
+
continue
|
|
44
|
+
if reviewed:
|
|
45
|
+
cur.execute(
|
|
46
|
+
"INSERT OR REPLACE INTO reviewed_lines "
|
|
47
|
+
"(session_id, file_path, line_hash, context_hash, line_no) "
|
|
48
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
49
|
+
(session_id, file_path, lh, ch, ln),
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
cur.execute(
|
|
53
|
+
"DELETE FROM reviewed_lines "
|
|
54
|
+
"WHERE session_id = ? AND file_path = ? AND line_hash = ? AND context_hash = ?",
|
|
55
|
+
(session_id, file_path, lh, ch),
|
|
56
|
+
)
|
|
57
|
+
count += 1
|
|
58
|
+
conn.commit()
|
|
59
|
+
except Exception:
|
|
60
|
+
conn.rollback()
|
|
61
|
+
raise
|
|
62
|
+
|
|
63
|
+
return jsonify({"updated": count})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from flask import Blueprint, request, jsonify, current_app
|
|
3
|
+
from auditview.db.connection import open_db
|
|
4
|
+
from auditview.core.hashing import line_hash
|
|
5
|
+
from auditview.api.util import safe_path
|
|
6
|
+
|
|
7
|
+
bp = Blueprint("notes", __name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _note_row(r, is_apsw):
|
|
11
|
+
if is_apsw:
|
|
12
|
+
return {
|
|
13
|
+
"id": r[0],
|
|
14
|
+
"file_path": r[1],
|
|
15
|
+
"start_line": r[2],
|
|
16
|
+
"end_line": r[3],
|
|
17
|
+
"content": r[4],
|
|
18
|
+
"is_todo": bool(r[5]),
|
|
19
|
+
"is_orphaned": bool(r[6]),
|
|
20
|
+
"snapshot_text": r[7],
|
|
21
|
+
"created_at": r[8],
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
"id": r["id"],
|
|
25
|
+
"file_path": r["file_path"],
|
|
26
|
+
"start_line": r["start_line"],
|
|
27
|
+
"end_line": r["end_line"],
|
|
28
|
+
"content": r["content"],
|
|
29
|
+
"is_todo": bool(r["is_todo"]),
|
|
30
|
+
"is_orphaned": bool(r["is_orphaned"]),
|
|
31
|
+
"snapshot_text": r["snapshot_text"],
|
|
32
|
+
"created_at": r["created_at"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@bp.route("/sessions/<int:session_id>/notes", methods=["GET"])
|
|
37
|
+
def list_notes(session_id):
|
|
38
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
39
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
40
|
+
cur = conn.cursor()
|
|
41
|
+
|
|
42
|
+
row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
43
|
+
if row is None:
|
|
44
|
+
return jsonify({"error": "Session not found"}), 404
|
|
45
|
+
|
|
46
|
+
rows = cur.execute(
|
|
47
|
+
"SELECT id, file_path, start_line, end_line, content, is_todo, is_orphaned, snapshot_text, created_at "
|
|
48
|
+
"FROM notes WHERE session_id = ? ORDER BY file_path, start_line",
|
|
49
|
+
(session_id,),
|
|
50
|
+
).fetchall()
|
|
51
|
+
|
|
52
|
+
return jsonify([_note_row(r, is_apsw) for r in rows])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@bp.route("/sessions/<int:session_id>/notes", methods=["POST"])
|
|
56
|
+
def create_note(session_id):
|
|
57
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
58
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
59
|
+
cur = conn.cursor()
|
|
60
|
+
|
|
61
|
+
session_row = cur.execute(
|
|
62
|
+
"SELECT id, root_path FROM sessions WHERE id = ?", (session_id,)
|
|
63
|
+
).fetchone()
|
|
64
|
+
if session_row is None:
|
|
65
|
+
return jsonify({"error": "Session not found"}), 404
|
|
66
|
+
|
|
67
|
+
root_path = session_row[1] if is_apsw else session_row["root_path"]
|
|
68
|
+
|
|
69
|
+
data = request.get_json(force=True, silent=True) or {}
|
|
70
|
+
file_path = data.get("file_path", "").strip()
|
|
71
|
+
start_line = data.get("start_line")
|
|
72
|
+
end_line = data.get("end_line")
|
|
73
|
+
content = data.get("content", "").strip()
|
|
74
|
+
is_todo = bool(data.get("is_todo", False))
|
|
75
|
+
|
|
76
|
+
if not file_path:
|
|
77
|
+
return jsonify({"error": "file_path is required"}), 400
|
|
78
|
+
if not content:
|
|
79
|
+
return jsonify({"error": "content is required"}), 400
|
|
80
|
+
if start_line is None or end_line is None:
|
|
81
|
+
return jsonify({"error": "start_line and end_line are required"}), 400
|
|
82
|
+
if not isinstance(start_line, int) or not isinstance(end_line, int):
|
|
83
|
+
return jsonify({"error": "start_line and end_line must be integers"}), 400
|
|
84
|
+
if start_line > end_line or start_line < 1:
|
|
85
|
+
return jsonify({"error": "invalid line range"}), 400
|
|
86
|
+
|
|
87
|
+
full_path = safe_path(root_path, file_path)
|
|
88
|
+
if full_path is None:
|
|
89
|
+
return jsonify({"error": "Invalid path"}), 400
|
|
90
|
+
if not os.path.isfile(full_path):
|
|
91
|
+
return jsonify({"error": "File not found"}), 404
|
|
92
|
+
|
|
93
|
+
with open(full_path, "r", encoding="utf-8", errors="replace") as f:
|
|
94
|
+
file_lines = f.read().splitlines()
|
|
95
|
+
|
|
96
|
+
if start_line > len(file_lines) or end_line > len(file_lines):
|
|
97
|
+
return jsonify({"error": "line range out of bounds"}), 400
|
|
98
|
+
|
|
99
|
+
start_idx = start_line - 1
|
|
100
|
+
end_idx = end_line - 1
|
|
101
|
+
start_hash = line_hash(file_lines[start_idx])
|
|
102
|
+
end_hash = line_hash(file_lines[end_idx])
|
|
103
|
+
snapshot_text = "\n".join(file_lines[start_idx:end_idx + 1])
|
|
104
|
+
|
|
105
|
+
cur.execute(
|
|
106
|
+
"INSERT INTO notes (session_id, file_path, start_line, end_line, start_hash, end_hash, snapshot_text, content, is_todo) "
|
|
107
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
108
|
+
(session_id, file_path, start_line, end_line, start_hash, end_hash, snapshot_text, content, int(is_todo)),
|
|
109
|
+
)
|
|
110
|
+
if is_apsw:
|
|
111
|
+
row_id = conn.last_insert_rowid()
|
|
112
|
+
else:
|
|
113
|
+
row_id = cur.lastrowid
|
|
114
|
+
|
|
115
|
+
row = cur.execute(
|
|
116
|
+
"SELECT id, file_path, start_line, end_line, content, is_todo, is_orphaned, snapshot_text, created_at "
|
|
117
|
+
"FROM notes WHERE id = ?",
|
|
118
|
+
(row_id,),
|
|
119
|
+
).fetchone()
|
|
120
|
+
|
|
121
|
+
return jsonify(_note_row(row, is_apsw)), 201
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@bp.route("/sessions/<int:session_id>/notes/<int:note_id>", methods=["PATCH"])
|
|
125
|
+
def update_note(session_id, note_id):
|
|
126
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
127
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
128
|
+
cur = conn.cursor()
|
|
129
|
+
|
|
130
|
+
row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
131
|
+
if row is None:
|
|
132
|
+
return jsonify({"error": "Session not found"}), 404
|
|
133
|
+
|
|
134
|
+
note_row = cur.execute(
|
|
135
|
+
"SELECT id FROM notes WHERE id = ? AND session_id = ?", (note_id, session_id)
|
|
136
|
+
).fetchone()
|
|
137
|
+
if note_row is None:
|
|
138
|
+
return jsonify({"error": "Note not found"}), 404
|
|
139
|
+
|
|
140
|
+
data = request.get_json(force=True, silent=True) or {}
|
|
141
|
+
updates = {}
|
|
142
|
+
if "content" in data:
|
|
143
|
+
content = data["content"].strip()
|
|
144
|
+
if not content:
|
|
145
|
+
return jsonify({"error": "content cannot be empty"}), 400
|
|
146
|
+
updates["content"] = content
|
|
147
|
+
if "is_todo" in data:
|
|
148
|
+
updates["is_todo"] = int(bool(data["is_todo"]))
|
|
149
|
+
|
|
150
|
+
if not updates:
|
|
151
|
+
return jsonify({"error": "nothing to update"}), 400
|
|
152
|
+
|
|
153
|
+
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
|
154
|
+
cur.execute(
|
|
155
|
+
f"UPDATE notes SET {set_clause} WHERE id = ?",
|
|
156
|
+
(*updates.values(), note_id),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
row = cur.execute(
|
|
160
|
+
"SELECT id, file_path, start_line, end_line, content, is_todo, is_orphaned, snapshot_text, created_at "
|
|
161
|
+
"FROM notes WHERE id = ?",
|
|
162
|
+
(note_id,),
|
|
163
|
+
).fetchone()
|
|
164
|
+
return jsonify(_note_row(row, is_apsw))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@bp.route("/sessions/<int:session_id>/notes/<int:note_id>", methods=["DELETE"])
|
|
168
|
+
def delete_note(session_id, note_id):
|
|
169
|
+
conn = open_db(current_app.config["DB_PATH"])
|
|
170
|
+
is_apsw = getattr(conn, '_is_apsw', False)
|
|
171
|
+
cur = conn.cursor()
|
|
172
|
+
|
|
173
|
+
row = cur.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
174
|
+
if row is None:
|
|
175
|
+
return jsonify({"error": "Session not found"}), 404
|
|
176
|
+
|
|
177
|
+
note_row = cur.execute(
|
|
178
|
+
"SELECT id FROM notes WHERE id = ? AND session_id = ?", (note_id, session_id)
|
|
179
|
+
).fetchone()
|
|
180
|
+
if note_row is None:
|
|
181
|
+
return jsonify({"error": "Note not found"}), 404
|
|
182
|
+
|
|
183
|
+
cur.execute("DELETE FROM notes WHERE id = ?", (note_id,))
|
|
184
|
+
|
|
185
|
+
return jsonify({"deleted": True})
|