mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
fix(db): self-heal missing repository prompt schema
Recover inconsistent SQLite states where migration v3 is marked applied but repository_review_prompts objects are absent, preventing admin repository listing failures in docker deployments. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
@@ -33,6 +33,8 @@ const MIGRATIONS: Migration[] = [
|
||||
migration003RepositoryReviewPrompts,
|
||||
];
|
||||
|
||||
const REPOSITORY_REVIEW_PROMPTS_TABLE = 'repository_review_prompts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,11 +74,39 @@ export function initDatabase(): Database {
|
||||
|
||||
// Run migrations
|
||||
runMigrations(db);
|
||||
ensureRepositoryReviewPromptsSchema(db);
|
||||
|
||||
console.log(`📦 Database initialized at ${dbPath}`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function doesTableExist(database: Database, tableName: string): boolean {
|
||||
const row = database
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { name?: string } | null;
|
||||
return row?.name === tableName;
|
||||
}
|
||||
|
||||
export function ensureRepositoryReviewPromptsSchema(database: Database = getDatabase()): void {
|
||||
if (doesTableExist(database, REPOSITORY_REVIEW_PROMPTS_TABLE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`⚠️ Detected inconsistent DB state: table '${REPOSITORY_REVIEW_PROMPTS_TABLE}' is missing. Rebuilding schema.`
|
||||
);
|
||||
|
||||
database.transaction(() => {
|
||||
migration003RepositoryReviewPrompts.up(database);
|
||||
|
||||
if (doesTableExist(database, '_migrations')) {
|
||||
database
|
||||
.query('INSERT OR IGNORE INTO _migrations (version, name) VALUES (?, ?)')
|
||||
.run(migration003RepositoryReviewPrompts.version, migration003RepositoryReviewPrompts.name);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database instance. Throws if not initialized.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
|
||||
|
||||
up(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE repository_review_prompts (
|
||||
CREATE TABLE IF NOT EXISTS repository_review_prompts (
|
||||
full_name TEXT PRIMARY KEY,
|
||||
project_prompt TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
@@ -15,7 +15,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
|
||||
`);
|
||||
|
||||
db.exec(
|
||||
'CREATE INDEX idx_repository_review_prompts_updated_at ON repository_review_prompts(updated_at)'
|
||||
'CREATE INDEX IF NOT EXISTS idx_repository_review_prompts_updated_at ON repository_review_prompts(updated_at)'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getDatabase } from '../database';
|
||||
import { toErrorLogMeta } from '../../utils/error-log';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ensureRepositoryReviewPromptsSchema, getDatabase } from '../database';
|
||||
|
||||
export interface RepositoryReviewPromptRow {
|
||||
full_name: string;
|
||||
@@ -10,8 +12,34 @@ function toFullName(owner: string, repo: string): string {
|
||||
return `${owner}/${repo}`;
|
||||
}
|
||||
|
||||
function isMissingPromptTableError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && error.message.includes('no such table: repository_review_prompts')
|
||||
);
|
||||
}
|
||||
|
||||
function withPromptTableHeal<T>(operation: string, run: () => T): T {
|
||||
try {
|
||||
return run();
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingPromptTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.warn('检测到 repository_review_prompts 表缺失,尝试自愈建表后重试', {
|
||||
operation,
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
error: toErrorLogMeta(error),
|
||||
});
|
||||
|
||||
ensureRepositoryReviewPromptsSchema();
|
||||
return run();
|
||||
}
|
||||
}
|
||||
|
||||
export const repositoryReviewPromptRepo = {
|
||||
getByFullName(fullName: string): RepositoryReviewPromptRow | null {
|
||||
return withPromptTableHeal('getByFullName', () => {
|
||||
const db = getDatabase();
|
||||
return (
|
||||
(db
|
||||
@@ -20,6 +48,7 @@ export const repositoryReviewPromptRepo = {
|
||||
)
|
||||
.get(fullName) as RepositoryReviewPromptRow | null) || null
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
getProjectPrompt(owner: string, repo: string): string | undefined {
|
||||
@@ -30,12 +59,13 @@ export const repositoryReviewPromptRepo = {
|
||||
},
|
||||
|
||||
upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow {
|
||||
const db = getDatabase();
|
||||
const normalized = projectPrompt.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('projectPrompt must be non-empty');
|
||||
}
|
||||
|
||||
return withPromptTableHeal('upsertByFullName', () => {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
@@ -49,6 +79,7 @@ export const repositoryReviewPromptRepo = {
|
||||
throw new Error('Failed to load repository review prompt after upsert');
|
||||
}
|
||||
return row;
|
||||
});
|
||||
},
|
||||
|
||||
setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow {
|
||||
@@ -56,11 +87,13 @@ export const repositoryReviewPromptRepo = {
|
||||
},
|
||||
|
||||
deleteByFullName(fullName: string): boolean {
|
||||
return withPromptTableHeal('deleteByFullName', () => {
|
||||
const db = getDatabase();
|
||||
const result = db
|
||||
.query('DELETE FROM repository_review_prompts WHERE full_name = ?')
|
||||
.run(fullName);
|
||||
return result.changes > 0;
|
||||
});
|
||||
},
|
||||
|
||||
clearProjectPrompt(owner: string, repo: string): boolean {
|
||||
@@ -73,6 +106,7 @@ export const repositoryReviewPromptRepo = {
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const loadPromptMap = (): Record<string, string> => {
|
||||
const placeholders = fullNames.map(() => '?').join(', ');
|
||||
const rows = db
|
||||
.query(
|
||||
@@ -80,7 +114,9 @@ export const repositoryReviewPromptRepo = {
|
||||
FROM repository_review_prompts
|
||||
WHERE full_name IN (${placeholders})`
|
||||
)
|
||||
.all(...fullNames) as Array<Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>>;
|
||||
.all(...fullNames) as Array<
|
||||
Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>
|
||||
>;
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
@@ -89,6 +125,64 @@ export const repositoryReviewPromptRepo = {
|
||||
map[row.full_name] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
try {
|
||||
return loadPromptMap();
|
||||
} catch (error: unknown) {
|
||||
if (isMissingPromptTableError(error)) {
|
||||
logger.warn('检测到 repository_review_prompts 表缺失,尝试自愈建表后重试', {
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
});
|
||||
|
||||
try {
|
||||
ensureRepositoryReviewPromptsSchema(db);
|
||||
return loadPromptMap();
|
||||
} catch (healError: unknown) {
|
||||
logger.error('自愈 repository_review_prompts 表后重试失败,降级返回空提示词映射', {
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
originalError: toErrorLogMeta(error),
|
||||
healError: toErrorLogMeta(healError),
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
let tableExists: boolean | null = null;
|
||||
let latestMigrationVersion: number | null = null;
|
||||
|
||||
try {
|
||||
const tableRow = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get('repository_review_prompts') as { name?: string } | null;
|
||||
tableExists = tableRow?.name === 'repository_review_prompts';
|
||||
|
||||
const migrationRow = db
|
||||
.query('SELECT version FROM _migrations ORDER BY version DESC LIMIT 1')
|
||||
.get() as { version?: number } | null;
|
||||
latestMigrationVersion = migrationRow?.version ?? null;
|
||||
} catch (inspectError: unknown) {
|
||||
logger.warn('查询项目级提示词失败后,诊断数据库状态时发生错误', {
|
||||
inspectError: toErrorLogMeta(inspectError),
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('批量查询项目级提示词失败', {
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
tableExists,
|
||||
latestMigrationVersion,
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
error: toErrorLogMeta(error),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user