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,
|
migration003RepositoryReviewPrompts,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const REPOSITORY_REVIEW_PROMPTS_TABLE = 'repository_review_prompts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Database singleton
|
// Database singleton
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -72,11 +74,39 @@ export function initDatabase(): Database {
|
|||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
|
ensureRepositoryReviewPromptsSchema(db);
|
||||||
|
|
||||||
console.log(`📦 Database initialized at ${dbPath}`);
|
console.log(`📦 Database initialized at ${dbPath}`);
|
||||||
return db;
|
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.
|
* Get the database instance. Throws if not initialized.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
|
|||||||
|
|
||||||
up(db: Database): void {
|
up(db: Database): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE repository_review_prompts (
|
CREATE TABLE IF NOT EXISTS repository_review_prompts (
|
||||||
full_name TEXT PRIMARY KEY,
|
full_name TEXT PRIMARY KEY,
|
||||||
project_prompt TEXT NOT NULL,
|
project_prompt TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
@@ -15,7 +15,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
db.exec(
|
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 {
|
export interface RepositoryReviewPromptRow {
|
||||||
full_name: string;
|
full_name: string;
|
||||||
@@ -10,16 +12,43 @@ function toFullName(owner: string, repo: string): string {
|
|||||||
return `${owner}/${repo}`;
|
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 = {
|
export const repositoryReviewPromptRepo = {
|
||||||
getByFullName(fullName: string): RepositoryReviewPromptRow | null {
|
getByFullName(fullName: string): RepositoryReviewPromptRow | null {
|
||||||
const db = getDatabase();
|
return withPromptTableHeal('getByFullName', () => {
|
||||||
return (
|
const db = getDatabase();
|
||||||
(db
|
return (
|
||||||
.query(
|
(db
|
||||||
'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
|
.query(
|
||||||
)
|
'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
|
||||||
.get(fullName) as RepositoryReviewPromptRow | null) || null
|
)
|
||||||
);
|
.get(fullName) as RepositoryReviewPromptRow | null) || null
|
||||||
|
);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getProjectPrompt(owner: string, repo: string): string | undefined {
|
getProjectPrompt(owner: string, repo: string): string | undefined {
|
||||||
@@ -30,25 +59,27 @@ export const repositoryReviewPromptRepo = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow {
|
upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow {
|
||||||
const db = getDatabase();
|
|
||||||
const normalized = projectPrompt.trim();
|
const normalized = projectPrompt.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new Error('projectPrompt must be non-empty');
|
throw new Error('projectPrompt must be non-empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
db.query(
|
return withPromptTableHeal('upsertByFullName', () => {
|
||||||
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
|
const db = getDatabase();
|
||||||
VALUES (?, ?, datetime('now'))
|
db.query(
|
||||||
ON CONFLICT(full_name) DO UPDATE SET
|
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
|
||||||
project_prompt = excluded.project_prompt,
|
VALUES (?, ?, datetime('now'))
|
||||||
updated_at = datetime('now')`
|
ON CONFLICT(full_name) DO UPDATE SET
|
||||||
).run(fullName, normalized);
|
project_prompt = excluded.project_prompt,
|
||||||
|
updated_at = datetime('now')`
|
||||||
|
).run(fullName, normalized);
|
||||||
|
|
||||||
const row = this.getByFullName(fullName);
|
const row = this.getByFullName(fullName);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new Error('Failed to load repository review prompt after upsert');
|
throw new Error('Failed to load repository review prompt after upsert');
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow {
|
setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow {
|
||||||
@@ -56,11 +87,13 @@ export const repositoryReviewPromptRepo = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteByFullName(fullName: string): boolean {
|
deleteByFullName(fullName: string): boolean {
|
||||||
const db = getDatabase();
|
return withPromptTableHeal('deleteByFullName', () => {
|
||||||
const result = db
|
const db = getDatabase();
|
||||||
.query('DELETE FROM repository_review_prompts WHERE full_name = ?')
|
const result = db
|
||||||
.run(fullName);
|
.query('DELETE FROM repository_review_prompts WHERE full_name = ?')
|
||||||
return result.changes > 0;
|
.run(fullName);
|
||||||
|
return result.changes > 0;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
clearProjectPrompt(owner: string, repo: string): boolean {
|
clearProjectPrompt(owner: string, repo: string): boolean {
|
||||||
@@ -73,22 +106,83 @@ export const repositoryReviewPromptRepo = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const placeholders = fullNames.map(() => '?').join(', ');
|
const loadPromptMap = (): Record<string, string> => {
|
||||||
const rows = db
|
const placeholders = fullNames.map(() => '?').join(', ');
|
||||||
.query(
|
const rows = db
|
||||||
`SELECT full_name, project_prompt
|
.query(
|
||||||
FROM repository_review_prompts
|
`SELECT full_name, project_prompt
|
||||||
WHERE full_name IN (${placeholders})`
|
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> = {};
|
const map: Record<string, string> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const normalized = row.project_prompt.trim();
|
const normalized = row.project_prompt.trim();
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
map[row.full_name] = normalized;
|
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;
|
||||||
}
|
}
|
||||||
return map;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user