@ossdeveloper/github-compliance 1.0.0

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.
package/compliance.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Compliance validation logic for GitHub write tools
3
+ *
4
+ * Handles tool identification, content hashing, and result parsing.
5
+ */
6
+
7
+ import { createHash } from "crypto";
8
+ import { SCHEMA, RECORD_STATUS } from "./schema";
9
+
10
+ /**
11
+ * List of GitHub write tools that require compliance validation
12
+ * Read-only tools are not included
13
+ */
14
+ const GITHUB_WRITE_TOOLS_UNPREFIXED = [
15
+ "issue_write",
16
+ "issue_update",
17
+ "pull_request_write",
18
+ "pull_request_update",
19
+ "add_issue_comment",
20
+ "add_pull_request_comment",
21
+ "pull_request_review",
22
+ "pull_request_review_comment",
23
+ "create_pull_request",
24
+ "update_issue",
25
+ "update_pull_request",
26
+ "push_files",
27
+ "create_branch",
28
+ "create_or_update_file",
29
+ "delete_file",
30
+ "create_repository",
31
+ "create_pull_request_with_copilot",
32
+ "update_pull_request_branch",
33
+ "reply_to_pull_request_comment",
34
+ "add_reply_to_pull_request_comment"
35
+ ] as const;
36
+
37
+ const GITHUB_WRITE_TOOLS_PREFIXED = GITHUB_WRITE_TOOLS_UNPREFIXED.map(t => `github_${t}`);
38
+
39
+ export const GITHUB_WRITE_TOOLS = [...GITHUB_WRITE_TOOLS_UNPREFIXED, ...GITHUB_WRITE_TOOLS_PREFIXED] as const;
40
+
41
+ export type GitHubWriteTool = typeof GITHUB_WRITE_TOOLS[number];
42
+
43
+ /**
44
+ * Extracted information from a tool call for compliance lookup
45
+ */
46
+ export interface ToolInfo {
47
+ toolName: string;
48
+ owner: string;
49
+ repo: string;
50
+ title: string | null;
51
+ body: string | null;
52
+ contentHash: string;
53
+ }
54
+
55
+ /**
56
+ * Check if a tool name is a GitHub write tool requiring compliance
57
+ *
58
+ * @param toolName - Name of the tool to check
59
+ * @returns True if the tool requires compliance validation
60
+ */
61
+ export function isGitHubWriteTool(toolName: string): boolean {
62
+ return GITHUB_WRITE_TOOLS.includes(toolName as GitHubWriteTool);
63
+ }
64
+
65
+ /**
66
+ * Extract compliance-relevant information from a tool call
67
+ *
68
+ * Different GitHub tools have different arguments, but they all share
69
+ * owner/repo and typically have title/body for content.
70
+ *
71
+ * @param toolName - Name of the tool being called
72
+ * @param args - Arguments passed to the tool
73
+ * @returns Extracted ToolInfo with content hash
74
+ */
75
+ export function extractToolInfo(toolName: string, args: Record<string, unknown>): ToolInfo {
76
+ const owner = (args.owner as string) || "";
77
+ const repo = (args.repo as string) || "";
78
+ const title = (args.title as string) || null;
79
+ const body = (args.body as string) || null;
80
+
81
+ return {
82
+ toolName,
83
+ owner,
84
+ repo,
85
+ title,
86
+ body,
87
+ contentHash: computeContentHash(title, body)
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Compute a SHA256 hash of the content for integrity verification
93
+ *
94
+ * The hash is computed over a normalized JSON string containing
95
+ * the content-determining fields. This ensures:
96
+ * - Same content = same hash
97
+ * - Different content = different hash
98
+ *
99
+ * @param title - Issue/PR title (or null)
100
+ * @param body - Issue/PR body (or null)
101
+ * @returns Hash string in format "sha256:..."
102
+ */
103
+ export function computeContentHash(title: string | null, body: string | null): string {
104
+ const content = JSON.stringify({
105
+ title: title || "",
106
+ body: body || ""
107
+ });
108
+ return "sha256:" + createHash("sha256").update(content).digest("hex");
109
+ }
110
+
111
+ /**
112
+ * Extract GitHub issue/PR ID from a tool execution result
113
+ *
114
+ * GitHub MCP tools return results with 'number' or 'id' fields.
115
+ * This helper extracts the numeric/string ID for audit logging.
116
+ *
117
+ * @param result - Raw tool execution result
118
+ * @returns GitHub ID if found, null otherwise
119
+ */
120
+ export function extractGitHubId(result: unknown): string | null {
121
+ if (!result || typeof result !== "object") return null;
122
+
123
+ const r = result as Record<string, unknown>;
124
+
125
+ if (typeof r.number === "number") {
126
+ return String(r.number);
127
+ }
128
+
129
+ if (typeof r.id === "string" || typeof r.id === "number") {
130
+ return String(r.id);
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Extract GitHub URL from a tool execution result
138
+ *
139
+ * GitHub MCP tools return results with 'html_url' or 'url' fields.
140
+ * This helper extracts the URL for audit logging.
141
+ *
142
+ * @param result - Raw tool execution result
143
+ * @returns GitHub URL if found, null otherwise
144
+ */
145
+ export function extractGitHubUrl(result: unknown): string | null {
146
+ if (!result || typeof result !== "object") return null;
147
+
148
+ const r = result as Record<string, unknown>;
149
+
150
+ if (typeof r.html_url === "string") {
151
+ return r.html_url;
152
+ }
153
+
154
+ if (typeof r.url === "string") {
155
+ return r.url;
156
+ }
157
+
158
+ return null;
159
+ }
package/database.ts ADDED
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Database operations for GitHub Compliance Plugin
3
+ *
4
+ * Manages SQLite database for compliance records, audit logs, and failed attempts.
5
+ * Uses centralized schema from schema.ts for all table/column names.
6
+ */
7
+
8
+ import Database from "bun:sqlite";
9
+ import { createHash } from "crypto";
10
+ import { mkdir } from "fs";
11
+ import { homedir } from "os";
12
+ import {
13
+ SCHEMA,
14
+ RECORD_STATUS,
15
+ RECORD_TTL_MS,
16
+ RECORD_CLEANUP_AGE_MS,
17
+ CONTENT_PREVIEW_MAX_LENGTH
18
+ } from "./schema";
19
+
20
+ const DB_DIR = `${homedir()}/.opencode`;
21
+ const DB_PATH = `${DB_DIR}/github-compliance.db`;
22
+
23
+ let db: Database | null = null;
24
+
25
+ // ============================================================================
26
+ // Types
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Compliance record stored in SQLite
31
+ * Represents approval for a specific GitHub write operation
32
+ */
33
+ export interface ComplianceRecord {
34
+ id: string;
35
+ tool_name: string;
36
+ owner: string;
37
+ repo: string;
38
+ title: string | null;
39
+ content_hash: string;
40
+ github_username: string;
41
+ approved_at: string;
42
+ expires_at: string;
43
+ status: typeof RECORD_STATUS[keyof typeof RECORD_STATUS];
44
+ created_at: string;
45
+ client_name: string;
46
+ policy_version: string;
47
+ }
48
+
49
+ /**
50
+ * Individual compliance check performed
51
+ * e.g., CONTRIBUTING.md read, duplicate search, etc.
52
+ */
53
+ export interface ComplianceCheck {
54
+ id: string;
55
+ compliance_record_id: string;
56
+ check_type: string;
57
+ check_passed: number;
58
+ details: string | null;
59
+ }
60
+
61
+ /**
62
+ * Audit log entry for successful operations
63
+ */
64
+ export interface AuditEntry {
65
+ compliance_record_id: string | null;
66
+ tool_name: string;
67
+ owner: string;
68
+ repo: string;
69
+ github_username: string;
70
+ status: string;
71
+ error_message: string | null;
72
+ github_url: string | null;
73
+ github_id: string | null;
74
+ executed_at: string;
75
+ content_preview: string | null;
76
+ }
77
+
78
+ /**
79
+ * Failed attempt record for rejected operations
80
+ */
81
+ export interface FailedAttemptEntry {
82
+ tool_name: string;
83
+ owner: string;
84
+ repo: string;
85
+ reason: string;
86
+ content_hash: string | null;
87
+ provided_record_id: string | null;
88
+ error_details: string | null;
89
+ }
90
+
91
+ /**
92
+ * Input for creating a new compliance record
93
+ */
94
+ export interface CreateRecordInput {
95
+ tool_name: string;
96
+ owner: string;
97
+ repo: string;
98
+ title: string | null;
99
+ body: string;
100
+ github_username: string;
101
+ approved_at: string;
102
+ checks: Array<{
103
+ check_type: string;
104
+ passed: boolean;
105
+ details: Record<string, unknown>;
106
+ }>;
107
+ }
108
+
109
+ // ============================================================================
110
+ // Helpers
111
+ // ============================================================================
112
+
113
+ function uuid(): string {
114
+ return crypto.randomUUID();
115
+ }
116
+
117
+ function computeContentHash(title: string | null, body: string): string {
118
+ const content = JSON.stringify({
119
+ title: title || "",
120
+ body: body || ""
121
+ });
122
+ return "sha256:" + createHash("sha256").update(content).digest("hex");
123
+ }
124
+
125
+ // ============================================================================
126
+ // Database Initialization
127
+ // ============================================================================
128
+
129
+ /**
130
+ * Initialize the SQLite database
131
+ *
132
+ * Creates the database file and tables if they don't exist.
133
+ * Tables: compliance_records, compliance_checks, audit_log, failed_attempts
134
+ */
135
+ export async function initDatabase(): Promise<void> {
136
+ if (db) return;
137
+
138
+ try {
139
+ await mkdir(DB_DIR, { recursive: true });
140
+ } catch {
141
+ // Directory may already exist
142
+ }
143
+
144
+ db = new Database(DB_PATH);
145
+ db.run("PRAGMA foreign_keys = ON");
146
+
147
+ // Main compliance records table
148
+ db.run(`
149
+ CREATE TABLE IF NOT EXISTS ${SCHEMA.tables.COMPLIANCE_RECORDS} (
150
+ ${SCHEMA.columns.ID} TEXT PRIMARY KEY,
151
+ ${SCHEMA.columns.TOOL_NAME} TEXT NOT NULL,
152
+ ${SCHEMA.columns.OWNER} TEXT NOT NULL,
153
+ ${SCHEMA.columns.REPO} TEXT NOT NULL,
154
+ ${SCHEMA.columns.TITLE} TEXT,
155
+ ${SCHEMA.columns.CONTENT_HASH} TEXT NOT NULL,
156
+ ${SCHEMA.columns.GITHUB_USERNAME} TEXT NOT NULL,
157
+ ${SCHEMA.columns.APPROVED_AT} TEXT NOT NULL,
158
+ ${SCHEMA.columns.EXPIRES_AT} TEXT NOT NULL,
159
+ ${SCHEMA.columns.STATUS} TEXT DEFAULT '${RECORD_STATUS.PENDING}',
160
+ ${SCHEMA.columns.CREATED_AT} TEXT NOT NULL,
161
+ ${SCHEMA.columns.CLIENT_NAME} TEXT DEFAULT 'opencode',
162
+ ${SCHEMA.columns.POLICY_VERSION} TEXT DEFAULT '1.0',
163
+ UNIQUE(${SCHEMA.columns.TOOL_NAME}, ${SCHEMA.columns.OWNER}, ${SCHEMA.columns.REPO}, ${SCHEMA.columns.CONTENT_HASH})
164
+ )
165
+ `);
166
+
167
+ // Individual checks performed for each record
168
+ db.run(`
169
+ CREATE TABLE IF NOT EXISTS ${SCHEMA.tables.COMPLIANCE_CHECKS} (
170
+ ${SCHEMA.columns.ID} TEXT PRIMARY KEY,
171
+ ${SCHEMA.columns.COMPLIANCE_RECORD_ID} TEXT NOT NULL,
172
+ ${SCHEMA.columns.CHECK_TYPE} TEXT NOT NULL,
173
+ ${SCHEMA.columns.CHECK_PASSED} INTEGER NOT NULL,
174
+ ${SCHEMA.columns.DETAILS} TEXT,
175
+ FOREIGN KEY (${SCHEMA.columns.COMPLIANCE_RECORD_ID}) REFERENCES ${SCHEMA.tables.COMPLIANCE_RECORDS}(${SCHEMA.columns.ID}) ON DELETE CASCADE
176
+ )
177
+ `);
178
+
179
+ // Audit log of all successful operations
180
+ db.run(`
181
+ CREATE TABLE IF NOT EXISTS ${SCHEMA.tables.AUDIT_LOG} (
182
+ ${SCHEMA.columns.ID} TEXT PRIMARY KEY,
183
+ ${SCHEMA.columns.COMPLIANCE_RECORD_ID} TEXT,
184
+ ${SCHEMA.columns.TOOL_NAME} TEXT NOT NULL,
185
+ ${SCHEMA.columns.OWNER} TEXT NOT NULL,
186
+ ${SCHEMA.columns.REPO} TEXT NOT NULL,
187
+ ${SCHEMA.columns.GITHUB_USERNAME} TEXT NOT NULL,
188
+ ${SCHEMA.columns.STATUS} TEXT NOT NULL,
189
+ ${SCHEMA.columns.ERROR_MESSAGE} TEXT,
190
+ ${SCHEMA.columns.GITHUB_URL} TEXT,
191
+ ${SCHEMA.columns.GITHUB_ID} TEXT,
192
+ ${SCHEMA.columns.EXECUTED_AT} TEXT NOT NULL,
193
+ ${SCHEMA.columns.CLIENT_NAME} TEXT DEFAULT 'opencode',
194
+ ${SCHEMA.columns.CONTENT_PREVIEW} TEXT
195
+ )
196
+ `);
197
+
198
+ // Failed attempts for debugging and analysis
199
+ db.run(`
200
+ CREATE TABLE IF NOT EXISTS ${SCHEMA.tables.FAILED_ATTEMPTS} (
201
+ ${SCHEMA.columns.ID} TEXT PRIMARY KEY,
202
+ ${SCHEMA.columns.TOOL_NAME} TEXT NOT NULL,
203
+ ${SCHEMA.columns.OWNER} TEXT NOT NULL,
204
+ ${SCHEMA.columns.REPO} TEXT NOT NULL,
205
+ ${SCHEMA.columns.REASON} TEXT NOT NULL,
206
+ ${SCHEMA.columns.CONTENT_HASH} TEXT,
207
+ ${SCHEMA.columns.PROVIDED_RECORD_ID} TEXT,
208
+ ${SCHEMA.columns.ATTEMPTED_AT} TEXT NOT NULL,
209
+ ${SCHEMA.columns.GITHUB_USERNAME} TEXT,
210
+ ${SCHEMA.columns.ERROR_DETAILS} TEXT
211
+ )
212
+ `);
213
+
214
+ // Indexes for performance
215
+ db.run(`CREATE INDEX IF NOT EXISTS ${SCHEMA.indexes.COMPLIANCE_LOOKUP} ON ${SCHEMA.tables.COMPLIANCE_RECORDS}(${SCHEMA.columns.TOOL_NAME}, ${SCHEMA.columns.OWNER}, ${SCHEMA.columns.REPO}, ${SCHEMA.columns.STATUS})`);
216
+ db.run(`CREATE INDEX IF NOT EXISTS ${SCHEMA.indexes.COMPLIANCE_EXPIRES} ON ${SCHEMA.tables.COMPLIANCE_RECORDS}(${SCHEMA.columns.EXPIRES_AT})`);
217
+ }
218
+
219
+ /**
220
+ * Get the database instance, initializing if needed
221
+ */
222
+ export async function getDatabase(): Promise<Database> {
223
+ if (!db) {
224
+ await initDatabase();
225
+ }
226
+ return db!;
227
+ }
228
+
229
+ // ============================================================================
230
+ // Compliance Records
231
+ // ============================================================================
232
+
233
+ /**
234
+ * Look up a compliance record by tool, repo, and content hash
235
+ *
236
+ * @param toolName - GitHub tool name (e.g., "issue_write")
237
+ * @param owner - Repository owner
238
+ * @param repo - Repository name
239
+ * @param contentHash - SHA256 hash of the content
240
+ * @returns Compliance record if found, null otherwise
241
+ */
242
+ export async function getComplianceRecord(
243
+ toolName: string,
244
+ owner: string,
245
+ repo: string,
246
+ contentHash: string
247
+ ): Promise<ComplianceRecord | null> {
248
+ const database = await getDatabase();
249
+ const stmt = database.prepare(`
250
+ SELECT * FROM ${SCHEMA.tables.COMPLIANCE_RECORDS}
251
+ WHERE ${SCHEMA.columns.TOOL_NAME} = ?
252
+ AND ${SCHEMA.columns.OWNER} = ?
253
+ AND ${SCHEMA.columns.REPO} = ?
254
+ AND ${SCHEMA.columns.CONTENT_HASH} = ?
255
+ `);
256
+
257
+ return stmt.get(toolName, owner, repo, contentHash) as ComplianceRecord | null;
258
+ }
259
+
260
+ /**
261
+ * Validation result returned by validateCompliance
262
+ */
263
+ export interface ValidationResult {
264
+ valid: boolean;
265
+ reason: string;
266
+ message: string;
267
+ }
268
+
269
+ /**
270
+ * Validate a compliance record for a tool call
271
+ *
272
+ * Checks:
273
+ * - Record exists
274
+ * - Status is 'approved' (not pending/used/expired)
275
+ * - Not expired (expires_at > now)
276
+ *
277
+ * @param record - Compliance record to validate (or null)
278
+ * @param toolInfo - Tool information for context
279
+ * @returns Validation result with reason if invalid
280
+ */
281
+ export async function validateCompliance(
282
+ record: ComplianceRecord | null,
283
+ toolInfo: { toolName: string; owner: string; repo: string; contentHash: string }
284
+ ): Promise<ValidationResult> {
285
+ if (!record) {
286
+ return {
287
+ valid: false,
288
+ reason: "NO_RECORD",
289
+ message: "No compliance record found for this operation."
290
+ };
291
+ }
292
+
293
+ if (record.status === RECORD_STATUS.PENDING) {
294
+ return {
295
+ valid: false,
296
+ reason: "NOT_APPROVED",
297
+ message: "Compliance record exists but has not been approved by user."
298
+ };
299
+ }
300
+
301
+ if (record.status === RECORD_STATUS.USED) {
302
+ return {
303
+ valid: false,
304
+ reason: "ALREADY_USED",
305
+ message: "This compliance record has already been used."
306
+ };
307
+ }
308
+
309
+ if (record.status === RECORD_STATUS.EXPIRED) {
310
+ return {
311
+ valid: false,
312
+ reason: "EXPIRED",
313
+ message: "Compliance record has expired."
314
+ };
315
+ }
316
+
317
+ const now = new Date();
318
+ const expiresAt = new Date(record.expires_at);
319
+
320
+ if (now > expiresAt) {
321
+ return {
322
+ valid: false,
323
+ reason: "EXPIRED",
324
+ message: "Compliance record has expired."
325
+ };
326
+ }
327
+
328
+ return { valid: true, reason: "", message: "" };
329
+ }
330
+
331
+ /**
332
+ * Mark a compliance record as 'used'
333
+ *
334
+ * Called after successful tool execution to prevent replay.
335
+ *
336
+ * @param recordId - ID of the record to mark as used
337
+ */
338
+ export async function markRecordUsed(recordId: string): Promise<void> {
339
+ const database = await getDatabase();
340
+ const stmt = database.prepare(`
341
+ UPDATE ${SCHEMA.tables.COMPLIANCE_RECORDS}
342
+ SET ${SCHEMA.columns.STATUS} = '${RECORD_STATUS.USED}'
343
+ WHERE ${SCHEMA.columns.ID} = ?
344
+ `);
345
+ stmt.run(recordId);
346
+ }
347
+
348
+ /**
349
+ * Create a new compliance record after user approval
350
+ *
351
+ * Called by the LLM via create_compliance_record tool after
352
+ * the user explicitly approves the draft.
353
+ *
354
+ * @param data - Record data including checks performed
355
+ * @returns UUID of the created record
356
+ */
357
+ export async function createComplianceRecord(data: CreateRecordInput): Promise<string> {
358
+ const database = await getDatabase();
359
+ const id = uuid();
360
+ const now = new Date();
361
+ const expiresAt = new Date(now.getTime() + RECORD_TTL_MS);
362
+ const contentHash = computeContentHash(data.title, data.body);
363
+
364
+ const stmt = database.prepare(`
365
+ INSERT INTO ${SCHEMA.tables.COMPLIANCE_RECORDS} (
366
+ ${SCHEMA.columns.ID},
367
+ ${SCHEMA.columns.TOOL_NAME},
368
+ ${SCHEMA.columns.OWNER},
369
+ ${SCHEMA.columns.REPO},
370
+ ${SCHEMA.columns.TITLE},
371
+ ${SCHEMA.columns.CONTENT_HASH},
372
+ ${SCHEMA.columns.GITHUB_USERNAME},
373
+ ${SCHEMA.columns.APPROVED_AT},
374
+ ${SCHEMA.columns.EXPIRES_AT},
375
+ ${SCHEMA.columns.STATUS},
376
+ ${SCHEMA.columns.CREATED_AT}
377
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '${RECORD_STATUS.APPROVED}', ?)
378
+ `);
379
+
380
+ stmt.run(
381
+ id,
382
+ data.tool_name,
383
+ data.owner,
384
+ data.repo,
385
+ data.title || null,
386
+ contentHash,
387
+ data.github_username,
388
+ data.approved_at,
389
+ expiresAt.toISOString(),
390
+ now.toISOString()
391
+ );
392
+
393
+ // Insert individual checks
394
+ for (const check of data.checks) {
395
+ const checkStmt = database.prepare(`
396
+ INSERT INTO ${SCHEMA.tables.COMPLIANCE_CHECKS} (
397
+ ${SCHEMA.columns.ID},
398
+ ${SCHEMA.columns.COMPLIANCE_RECORD_ID},
399
+ ${SCHEMA.columns.CHECK_TYPE},
400
+ ${SCHEMA.columns.CHECK_PASSED},
401
+ ${SCHEMA.columns.DETAILS}
402
+ ) VALUES (?, ?, ?, ?, ?)
403
+ `);
404
+ checkStmt.run(
405
+ uuid(),
406
+ id,
407
+ check.check_type,
408
+ check.passed ? 1 : 0,
409
+ JSON.stringify(check.details)
410
+ );
411
+ }
412
+
413
+ return id;
414
+ }
415
+
416
+ /**
417
+ * Check if a valid compliance record exists
418
+ *
419
+ * @param toolName - GitHub tool name
420
+ * @param owner - Repository owner
421
+ * @param repo - Repository name
422
+ * @param contentHash - SHA256 hash of content
423
+ * @returns Status of any existing record
424
+ */
425
+ export async function getComplianceStatus(
426
+ toolName: string,
427
+ owner: string,
428
+ repo: string,
429
+ contentHash: string
430
+ ): Promise<{ exists: boolean; status: string | null; expires_at: string | null; approved_at: string | null }> {
431
+ const record = await getComplianceRecord(toolName, owner, repo, contentHash);
432
+
433
+ if (!record) {
434
+ return { exists: false, status: null, expires_at: null, approved_at: null };
435
+ }
436
+
437
+ return {
438
+ exists: true,
439
+ status: record.status,
440
+ expires_at: record.expires_at,
441
+ approved_at: record.approved_at
442
+ };
443
+ }
444
+
445
+ // ============================================================================
446
+ // Audit Logging
447
+ // ============================================================================
448
+
449
+ /**
450
+ * Log a successful operation to the audit trail
451
+ *
452
+ * @param entry - Audit entry with operation details
453
+ */
454
+ export async function logAudit(entry: AuditEntry): Promise<void> {
455
+ const database = await getDatabase();
456
+ const stmt = database.prepare(`
457
+ INSERT INTO ${SCHEMA.tables.AUDIT_LOG} (
458
+ ${SCHEMA.columns.ID},
459
+ ${SCHEMA.columns.COMPLIANCE_RECORD_ID},
460
+ ${SCHEMA.columns.TOOL_NAME},
461
+ ${SCHEMA.columns.OWNER},
462
+ ${SCHEMA.columns.REPO},
463
+ ${SCHEMA.columns.GITHUB_USERNAME},
464
+ ${SCHEMA.columns.STATUS},
465
+ ${SCHEMA.columns.ERROR_MESSAGE},
466
+ ${SCHEMA.columns.GITHUB_URL},
467
+ ${SCHEMA.columns.GITHUB_ID},
468
+ ${SCHEMA.columns.EXECUTED_AT},
469
+ ${SCHEMA.columns.CLIENT_NAME},
470
+ ${SCHEMA.columns.CONTENT_PREVIEW}
471
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
472
+ `);
473
+ stmt.run(
474
+ uuid(),
475
+ entry.compliance_record_id,
476
+ entry.tool_name,
477
+ entry.owner,
478
+ entry.repo,
479
+ entry.github_username,
480
+ entry.status,
481
+ entry.error_message || null,
482
+ entry.github_url || null,
483
+ entry.github_id || null,
484
+ entry.executed_at,
485
+ "opencode",
486
+ entry.content_preview ? entry.content_preview.substring(0, CONTENT_PREVIEW_MAX_LENGTH) : null
487
+ );
488
+ }
489
+
490
+ /**
491
+ * Log a failed/rejected attempt for debugging and analysis
492
+ *
493
+ * @param entry - Failed attempt entry with rejection reason
494
+ */
495
+ export async function logFailedAttempt(entry: FailedAttemptEntry): Promise<void> {
496
+ const database = await getDatabase();
497
+ const stmt = database.prepare(`
498
+ INSERT INTO ${SCHEMA.tables.FAILED_ATTEMPTS} (
499
+ ${SCHEMA.columns.ID},
500
+ ${SCHEMA.columns.TOOL_NAME},
501
+ ${SCHEMA.columns.OWNER},
502
+ ${SCHEMA.columns.REPO},
503
+ ${SCHEMA.columns.REASON},
504
+ ${SCHEMA.columns.CONTENT_HASH},
505
+ ${SCHEMA.columns.PROVIDED_RECORD_ID},
506
+ ${SCHEMA.columns.ATTEMPTED_AT},
507
+ ${SCHEMA.columns.GITHUB_USERNAME},
508
+ ${SCHEMA.columns.ERROR_DETAILS}
509
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
510
+ `);
511
+ stmt.run(
512
+ uuid(),
513
+ entry.tool_name,
514
+ entry.owner,
515
+ entry.repo,
516
+ entry.reason,
517
+ entry.content_hash || null,
518
+ entry.provided_record_id || null,
519
+ new Date().toISOString(),
520
+ null,
521
+ entry.error_details || null
522
+ );
523
+ }
524
+
525
+ // ============================================================================
526
+ // Cleanup
527
+ // ============================================================================
528
+
529
+ /**
530
+ * Mark all expired records as 'expired'
531
+ * Should be called periodically (e.g., on startup)
532
+ *
533
+ * @returns Number of records marked as expired
534
+ */
535
+ export async function cleanupExpiredRecords(): Promise<number> {
536
+ const database = await getDatabase();
537
+ const now = new Date().toISOString();
538
+ const stmt = database.prepare(`
539
+ UPDATE ${SCHEMA.tables.COMPLIANCE_RECORDS}
540
+ SET ${SCHEMA.columns.STATUS} = '${RECORD_STATUS.EXPIRED}'
541
+ WHERE ${SCHEMA.columns.EXPIRES_AT} < ?
542
+ AND ${SCHEMA.columns.STATUS} = '${RECORD_STATUS.APPROVED}'
543
+ `);
544
+ const result = stmt.run(now);
545
+ return result.changes;
546
+ }
547
+
548
+ /**
549
+ * Delete old records (older than 7 days)
550
+ * Cleanup job for maintenance
551
+ *
552
+ * @returns Number of records deleted
553
+ */
554
+ export async function cleanupOldRecords(): Promise<number> {
555
+ const database = await getDatabase();
556
+ const cutoff = new Date(Date.now() - RECORD_CLEANUP_AGE_MS).toISOString();
557
+ const stmt = database.prepare(`
558
+ DELETE FROM ${SCHEMA.tables.COMPLIANCE_RECORDS}
559
+ WHERE ${SCHEMA.columns.EXPIRES_AT} < ?
560
+ `);
561
+ const result = stmt.run(cutoff);
562
+ return result.changes;
563
+ }