| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | """Import a verified personal Forgejo export into stopped Shale, without replacing native issues.""" |
| 3 | 3 | import argparse |
| 4 | | from collections import defaultdict |
| 4 | from collections import Counter, defaultdict |
| 5 | 5 | from datetime import datetime, timezone |
| 6 | 6 | import hashlib |
| 7 | 7 | import grp |
| ... | ... | @@ -189,6 +189,7 @@ def migrate(database, source, attachments, target, evidence, before_commit=lambd |
| 189 | 189 | assignees = defaultdict(list) |
| 190 | 190 | for row in data['assignees']: |
| 191 | 191 | assignees[row['issue_id']].append(users[row['assignee_id']]) |
| 192 | planned = [] |
| 192 | 193 | for row in sorted(data['issues'], key=lambda r: (r['repo_id'], r['index'])): |
| 193 | 194 | repo = repos[sources[row['repo_id']]['name']] |
| 194 | 195 | existing = saved('issue', row['id']) |
| ... | ... | @@ -198,14 +199,18 @@ def migrate(database, source, attachments, target, evidence, before_commit=lambd |
| 198 | 199 | ceilings[repo['id']] += 1 |
| 199 | 200 | number = ceilings[repo['id']] |
| 200 | 201 | occupied[repo['id']].add(number) |
| 202 | else: |
| 203 | number = db.execute('SELECT number FROM issues WHERE id=?', (existing,)).fetchone()[0] |
| 204 | planned.append((repo, number, existing, row)) |
| 205 | # Shale allocates the next number from the issue with the highest row ID. |
| 206 | for repo, number, existing, row in sorted(planned, key=lambda item: (item[0]['id'], item[1])): |
| 207 | if existing is None: |
| 201 | 208 | existing = db.execute('INSERT INTO issues(uuid,repo,number,author,last_updated,title,status,assignee) VALUES(?,?,?,?,?,?,?,?)', |
| 202 | 209 | (identifier('issue', row['id'], row['created_unix']), repo['id'], number, |
| 203 | 210 | actor(row), timestamp(row['updated_unix']), row['name'], |
| 204 | 211 | 'done' if row['is_closed'] else 'todo', |
| 205 | 212 | assignees[row['id']][0] if len(assignees[row['id']]) == 1 else None)).lastrowid |
| 206 | 213 | record('issue', row['id'], existing, row) |
| 207 | | else: |
| 208 | | number = db.execute('SELECT number FROM issues WHERE id=?', (existing,)).fetchone()[0] |
| 209 | 214 | if number != row['index']: |
| 210 | 215 | collisions.append({'repo': repo['name'], 'forgejo': row['index'], 'shale': number}) |
| 211 | 216 | issue_map[row['id']] = {'id': existing, 'number': number, 'repo': repo['name'], |
| ... | ... | @@ -381,6 +386,9 @@ def migrate(database, source, attachments, target, evidence, before_commit=lambd |
| 381 | 386 | raise ValueError(f'Existing Shale {table} row changed') |
| 382 | 387 | if db.execute('PRAGMA foreign_key_check').fetchall(): |
| 383 | 388 | raise ValueError('Imported data has invalid foreign keys') |
| 389 | if db.execute('SELECT 1 FROM issues i WHERE i.id=(SELECT max(id) FROM issues WHERE repo=i.repo) ' |
| 390 | 'AND i.number<>(SELECT max(number) FROM issues WHERE repo=i.repo)').fetchone(): |
| 391 | raise ValueError('Issue row order would reuse a number; repair it before importing') |
| 384 | 392 | for kind, rows in [('issue', data['issues']), ('forgejo-comment', data['comments']), ('label', data['labels']), |
| 385 | 393 | ('issue-label', data['issue_labels']), ('attachment', data['attachments']), ('history', data['history'])]: |
| 386 | 394 | count = db.execute('SELECT count(*) FROM studio_forgejo_records WHERE kind=?', (kind,)).fetchone()[0] |
| ... | ... | @@ -398,6 +406,69 @@ def migrate(database, source, attachments, target, evidence, before_commit=lambd |
| 398 | 406 | print(json.dumps({k:v for k,v in report.items() if k != 'issue_mapping'}, indent=2)) |
| 399 | 407 | |
| 400 | 408 | |
| 409 | def repair_issue_order(database, evidence, before_commit): |
| 410 | with sqlite3.connect(database) as db: |
| 411 | db.row_factory = sqlite3.Row |
| 412 | db.execute('PRAGMA foreign_keys=ON') |
| 413 | with sqlite3.connect(evidence / 'before.db') as backup: |
| 414 | db.backup(backup) |
| 415 | db.execute('BEGIN IMMEDIATE') |
| 416 | db.execute('PRAGMA defer_foreign_keys=ON') |
| 417 | if db.execute('PRAGMA integrity_check').fetchone()[0] != 'ok' or db.execute('PRAGMA foreign_key_check').fetchall(): |
| 418 | raise ValueError('Shale database integrity failed') |
| 419 | if db.execute('SELECT 1 FROM issues GROUP BY repo,number HAVING count(*)>1').fetchone(): |
| 420 | raise ValueError('Duplicate issue numbers require separate review') |
| 421 | tables = [r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")] |
| 422 | if any(not re.fullmatch('[a-z_]+', table) for table in tables): |
| 423 | raise ValueError('Unexpected table name') |
| 424 | references = {table: [r['from'] for r in db.execute(f'PRAGMA foreign_key_list({table})') if r['table'] == 'issues'] for table in tables} |
| 425 | for table in tables: |
| 426 | for column in db.execute(f'PRAGMA table_info({table})'): |
| 427 | if re.fullmatch('(.*_)?issue(_id)?', column['name']) and column['name'] not in references[table]: |
| 428 | raise ValueError('Undeclared issue reference requires review') |
| 429 | imported = {r[0] for r in db.execute("SELECT target_id FROM studio_forgejo_records WHERE kind='issue'")} |
| 430 | swaps = {} |
| 431 | for repo in db.execute('SELECT DISTINCT repo FROM issues'): |
| 432 | newest = db.execute('SELECT id,number FROM issues WHERE repo=? ORDER BY id DESC LIMIT 1', repo).fetchone() |
| 433 | highest = db.execute('SELECT id,number FROM issues WHERE repo=? ORDER BY number DESC LIMIT 1', repo).fetchone() |
| 434 | if newest['number'] != highest['number']: |
| 435 | if newest['id'] not in imported or highest['id'] not in imported: |
| 436 | raise ValueError('Repair would change a native issue ID') |
| 437 | swaps[newest['id']], swaps[highest['id']] = highest['id'], newest['id'] |
| 438 | |
| 439 | def contents(): |
| 440 | identities = dict(db.execute('SELECT id,uuid FROM issues')) |
| 441 | result = {} |
| 442 | for table in tables: |
| 443 | rows = [] |
| 444 | for record in db.execute(f'SELECT * FROM {table}'): |
| 445 | row = dict(record) |
| 446 | if table == 'issues': |
| 447 | row['id'] = identities[row['id']] if row['id'] in imported else row['id'] |
| 448 | for column in references[table]: |
| 449 | row[column] = identities[row[column]] |
| 450 | if table == 'studio_forgejo_records' and row['kind'] == 'issue': |
| 451 | row['target_id'] = identities[row['target_id']] |
| 452 | rows.append(tuple(row.items())) |
| 453 | result[table] = Counter(rows) |
| 454 | return result |
| 455 | |
| 456 | before = contents() |
| 457 | for old, new in [*((old, -old) for old in swaps), *((-old, new) for old, new in swaps.items())]: |
| 458 | db.execute('UPDATE issues SET id=? WHERE id=?', (new, old)) |
| 459 | for table, columns in references.items(): |
| 460 | for column in columns: |
| 461 | db.execute(f'UPDATE {table} SET {column}=? WHERE {column}=?', (new, old)) |
| 462 | db.execute("UPDATE studio_forgejo_records SET target_id=? WHERE kind='issue' AND target_id=?", (new, old)) |
| 463 | if before != contents() or db.execute('PRAGMA foreign_key_check').fetchall(): |
| 464 | raise ValueError('Repair changed issue content or references') |
| 465 | before_commit() |
| 466 | db.commit() |
| 467 | report = {'surrogate_id_swaps': swaps, 'all_table_contents_preserved': True} |
| 468 | (evidence / 'report.json').write_text(json.dumps(report, indent=2) + '\n') |
| 469 | print(json.dumps(report)) |
| 470 | |
| 471 | |
| 401 | 472 | def main(): |
| 402 | 473 | parser = argparse.ArgumentParser() |
| 403 | 474 | parser.add_argument('--target', type=Path, required=True) |
| ... | ... | @@ -405,6 +476,7 @@ def main(): |
| 405 | 476 | parser.add_argument('--proof', type=Path, help='Verified export proof; defaults to source-proof.json beside the source') |
| 406 | 477 | parser.add_argument('--attachments', type=Path, default=Path('/mnt/storage1/apps/forgejo/work/attachments')) |
| 407 | 478 | parser.add_argument('--rehearsal', action='store_true') |
| 479 | parser.add_argument('--repair-issue-order', action='store_true') |
| 408 | 480 | args = parser.parse_args() |
| 409 | 481 | if os.geteuid() != 0: |
| 410 | 482 | parser.error('Run as root on Zenith') |
| ... | ... | @@ -429,7 +501,10 @@ def main(): |
| 429 | 501 | parser.error('Verified export proof is missing or the source checksum differs') |
| 430 | 502 | guard() |
| 431 | 503 | evidence = Path(tempfile.mkdtemp(prefix='issue-import-', dir=str(args.source.parent))) |
| 432 | | migrate(target / 'data/astheno.shale.db', args.source, args.attachments, target, evidence, guard) |
| 504 | if args.repair_issue_order: |
| 505 | repair_issue_order(target / 'data/astheno.shale.db', evidence, guard) |
| 506 | else: |
| 507 | migrate(target / 'data/astheno.shale.db', args.source, args.attachments, target, evidence, guard) |
| 433 | 508 | print('Evidence:', evidence) |
| 434 | 509 | |
| 435 | 510 | |