Add progress logging and reliable GitHub mirror enrichment

This commit is contained in:
omen committed 2026-10-27 17:55:53 +01:00
1 parent b52f880066
commit 10b34804f2
8 files changed
+475 -12

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
import subprocess
import tempfile
import threading
from pathlib import Path
from http.server import ThreadingHTTPServer
# Reuse the mock API definitions without running its full regression suite.
ns = {'__file__': str(Path(__file__).with_name('test_sync.py'))}
exec(Path(ns['__file__']).read_text().split('server=ThreadingHTTPServer')[0], ns)
class InvalidJSON(ns['Handler']):
def do_GET(self):
if self.path == '/gh/repos/first/alpha':
self.send_response(200)
self.end_headers()
self.wfile.write(b'not-json')
else:
super().do_GET()
ns['reset']()
ns['repos'] = ns['repos'][:1]
server = ThreadingHTTPServer(('127.0.0.1', 0), InvalidJSON)
threading.Thread(target=server.serve_forever, daemon=True).start()
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / 'config'
config.write_text(f"GITEA_TOKEN=test\nGITEA_URL=http://127.0.0.1:{server.server_port}\nGITHUB_API=http://127.0.0.1:{server.server_port}/gh\nLOCK_FILE={tmp}/lock\n")
result = subprocess.run(['bash', ns['SCRIPT'], '--config', str(config), '--apply', '--no-git'], capture_output=True, text=True)
assert result.returncode != 0, result.stderr
assert 'failed=1' in result.stderr and 'unavailable=0' in result.stderr, result.stderr
assert 'Invalid JSON' in result.stderr and not ns['writes'], result.stderr
server.shutdown()
print('PASS invalid JSON remains a genuine failure, not an unavailable source')
+20
View File
@@ -0,0 +1,20 @@
import subprocess
from pathlib import Path
source = (Path(__file__).resolve().parents[1] / 'gitea-github-sync.sh').read_text()
progress = source[source.index('PROGRESS_LAST=-1'):source.index('on_error() {')]
program = "set -Eeuo pipefail\nlog() { printf '%s\\n' \"$*\"; }\nPROGRESS_INTERVAL=60\n" + progress + """
SECONDS=0
progress "Reading comments" "processed=0"
SECONDS=59
progress "Reading comments" "processed=59"
SECONDS=60
progress "Reading comments" "processed=60"
progress "Applying issues" "processed=0"
"""
result = subprocess.run(['bash', '-c', program], capture_output=True, text=True, check=True)
lines = result.stdout.splitlines()
assert len(lines) == 3, result.stdout
assert 'processed=59' not in result.stdout
assert 'processed=60' in lines[1] and 'Applying issues' in lines[2]
print('PASS progress interval, counters and immediate stage transitions')
+239
View File
@@ -0,0 +1,239 @@
import copy
import json
import subprocess
import tempfile
import threading
import time
from pathlib import Path
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit, parse_qs
SCRIPT = str(Path(__file__).resolve().parents[1] / 'gitea-github-sync.sh')
repos = []
reads = []
writes = []
fail_repo = None
bad_discovery = False
failure_mode = 'permission'
request_gate = None
redirect_mode = None
def make(n, source, **kwargs):
r = dict(id=n, name=f'local-{n}', owner={'login':'adam'}, mirror=True,
original_url=source, archived=False, has_issues=True,
default_branch='main', description='old', website='', topics=[], issues=[], comments=[])
r.update(kwargs)
return r
def reset():
global repos, fail_repo, bad_discovery
fail_repo = None
bad_discovery = False
reads.clear()
writes.clear()
repos = [make(1, 'https://github.com/first/alpha.git'),
make(2, 'git@github.com:second/beta.git', private=True),
make(3, 'ssh://git@github.com:22/third/gamma.git/', has_issues=False),
make(4, 'https://gitlab.com/other/project.git'),
make(5, 'https://github.com/first/alpha.git', mirror=False),
make(6, 'https://github.com/first/alpha.git', owner={'login':'someone-else'}),
make(7, 'https://github.com.evil.invalid/a/b.git'),
make(8, 'https://user:password@GitHub.com/Fourth/delta.git/')]
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): pass
def answer(self, code, obj=None, headers=None):
self.send_response(code)
self.send_header('Content-Type', 'application/json')
for k,v in (headers or {}).items(): self.send_header(k,str(v))
self.end_headers()
if obj is not None: self.wfile.write(json.dumps(obj).encode())
def do_GET(self):
global fail_repo
if request_gate is not None: request_gate.wait(timeout=10)
reads.append(self.path)
u = urlsplit(self.path)
page = int(parse_qs(u.query).get('page', ['1'])[0])
if u.path == '/api/v1/user': return self.answer(200, {'login':'adam'})
if u.path == '/api/v1/users/adam/repos':
return self.answer(200, {'error':'invalid'} if bad_discovery else repos[page-1:page])
if u.path.startswith('/gh/') and redirect_mode:
if redirect_mode == 'external':
return self.answer(301, {}, {'Location':'https://example.invalid/steal'})
if redirect_mode == 'loop':
return self.answer(301, {}, {'Location':self.path})
if not u.path.startswith('/gh/moved/'):
return self.answer(301, {}, {'Location':self.path.replace('/gh/', '/gh/moved/', 1)})
gh = u.path.startswith('/gh/')
parts = u.path.split('/repos/')[-1].split('/')
owner, name = parts[:2]
tail = '/' + '/'.join(parts[2:]) if len(parts)>2 else ''
mapping = {'alpha':1, 'beta':2, 'gamma':3, 'delta':8}
if gh:
if name not in mapping: return self.answer(404, {})
n = mapping[name]
expected = {1:'first',2:'second',3:'third',8:'Fourth'}[n]
assert owner == expected
else:
assert owner == 'adam'
n = int(name.removeprefix('local-'))
r = next(x for x in repos if x['id']==n)
if gh and n == fail_repo:
if failure_mode == 'primary':
return self.answer(403, {'message':'API rate limit exceeded'}, {'X-RateLimit-Remaining':0,'X-RateLimit-Reset':int(time.time())+3600})
if failure_mode == 'secondary':
return self.answer(403, {'message':'You have exceeded a secondary rate limit'})
if failure_mode == 'retry':
fail_repo = None
return self.answer(429, {'message':'Too many requests'}, {'Retry-After':1})
if failure_mode == 'missing': return self.answer(404, {'message':'Not Found'})
if failure_mode == 'auth': return self.answer(401, {'message':'Bad credentials'})
return self.answer(403, {'message':'denied'})
if tail == '': return self.answer(200, dict(id=n, default_branch='main', description=f'source-{n}', homepage=f'https://example.com/{n}') if gh else r)
if tail == '/topics': return self.answer(200, {'names' if gh else 'topics':[f'topic-{n}'] if gh else r['topics']})
if tail == '/issues':
assert r['has_issues'], 'must not request disabled issues'
data = [dict(id=100+n, number=1, title=f'Issue-{n}', body=f'Body-{n}', state='closed')] if gh else r['issues']
elif tail == '/issues/comments':
assert r['has_issues']
data = [dict(id=200+n, body=f'Comment-{n}', issue_url=f'https://api.github.com/repos/{owner}/{name}/issues/1')] if gh else r['comments']
elif tail.endswith('/comments'): return self.answer(200, r['comments'])
else: return self.answer(404, {})
self.answer(200, data[page-1:page])
def write(self):
data = json.loads(self.rfile.read(int(self.headers.get('Content-Length',0))) or '{}')
parts = self.path.split('/repos/adam/local-')[1].split('/')
n = int(parts[0]); tail = '/'+'/'.join(parts[1:]) if len(parts)>1 else ''
writes.append((n,self.command,tail,data))
r = next(x for x in repos if x['id']==n)
if tail == '': r.update(data); return self.answer(200,r)
if tail == '/topics': r['topics']=data['topics']; return self.answer(204)
if tail == '/mirror-sync': return self.answer(200)
if tail == '/issues':
item = dict(data,id=1000+n,number=10,state='closed' if data['closed'] else 'open')
r['issues'].append(item); return self.answer(201,item)
if tail.endswith('/comments'):
item = dict(data,id=2000+n); r['comments'].append(item); return self.answer(201,item)
self.answer(404,{})
do_POST=write
do_PATCH=write
do_PUT=write
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
threading.Thread(target=server.serve_forever,daemon=True).start()
with tempfile.TemporaryDirectory() as tmp:
conf=Path(tmp)/'config'
conf.write_text(f"GITEA_TOKEN='test'\nGITEA_URL='http://127.0.0.1:{server.server_port}'\nGITHUB_API='http://127.0.0.1:{server.server_port}/gh'\nLOCK_FILE='{tmp}/lock'\nGITEA_REPO='obsolete-name'\nGITHUB_RATE_LIMIT_MAX_WAIT=2\n")
def run(mode='--apply', success=True):
p=subprocess.run(['bash',SCRIPT,'--config',str(conf),mode,'--no-git'],capture_output=True,text=True)
assert (p.returncode==0)==success,p.stderr
assert 'password' not in p.stderr
return p.stderr
reset()
out=run('--dry-run')
assert not writes and 'selected=4, succeeded=4, failed=0, skipped=4' in out,out
assert any('page=9' in p for p in reads)
assert 'Progress: Reading gt repositories.jsonl; page=1' in out,out
assert 'Progress: Comparing and synchronizing issues/comments' in out,out
print('PASS account discovery, full pagination, private mirror and source URL filters')
run()
assert {n for n,_,_,_ in writes}=={1,2,3,8}
for r in repos:
if r['id'] in {1,2,8}:
assert len(r['issues'])==len(r['comments'])==1
assert r['issues'][0]['title']==f"Issue-{r['id']}"
assert r['comments'][0]['body'].startswith(f"Comment-{r['id']}")
if r['id']==3: assert not r['issues'] and r['description']=='source-3'
writes.clear()
run()
assert not writes
print('PASS isolated state, renamed destinations, disabled issues, multi-repository idempotency')
repos[0]['comments'].extend(dict(id=9000+i,body=f'Local comment {i}') for i in range(60))
reads.clear(); run(); assert not writes
assert len([p for p in reads if p == '/api/v1/repos/adam/local-1/issues/10/comments']) == 1
assert len(repos[0]['comments']) == 61
print('PASS complete unpaginated list over 50 comments read once without duplicate writes')
reset(); fail_repo=1
out=run(success=False)
assert {n for n,_,_,_ in writes}=={2,3,8}
assert 'selected=4, succeeded=3, failed=1' in out,out
print('PASS one repository failure does not stop remaining repositories')
reset(); fail_repo=1; failure_mode='missing'
out=run()
assert 'unavailable=1' in out and 'failed=0' in out,out
assert 'Failure at line' not in out and 'FAILED ' not in out,out
assert {n for n,_,_,_ in writes}=={2,3,8}
failure_mode='permission'
print('PASS missing GitHub source preserved and counted separately without failing the run')
reset(); repos[0]['archived']=True
out=run(success=False)
assert {n for n,_,_,_ in writes}=={2,3,8}
print('PASS archived mirrors reported without blocking other repositories')
reset(); repos=[]
out=run()
assert not writes and 'discovered=0' in out
print('PASS empty account')
reset(); bad_discovery=True
run(success=False)
assert not writes
print('PASS discovery failure prevents writes')
for failure_mode in ['primary','secondary','auth']:
reset(); fail_repo=1
out=run(success=False)
assert not writes and 'Account scan aborted' in out,out
assert len([p for p in reads if p.startswith('/gh/')])==1,reads
if failure_mode=='primary': assert 'quota resets at' in out
if failure_mode=='auth': assert 'Bad credentials' in out
print(f'PASS {failure_mode} error is diagnosed and stops further GitHub requests')
failure_mode='retry'
reset(); fail_repo=1
out=run()
assert 'Waiting 1 seconds' in out and 'succeeded=4' in out,out
assert {n for n,_,_,_ in writes}=={1,2,3,8}
print('PASS Retry-After respected and synchronization resumes')
reset(); redirect_mode='same'
out=run()
assert 'Following GitHub HTTP 301' in out and 'succeeded=4' in out,out
writes.clear(); run(); assert not writes
print('PASS same-origin redirects and idempotent unpaginated Gitea comments')
for redirect_mode in ['external','loop']:
reset(); out=run(success=False)
assert not writes
assert ('Rejected GitHub redirect' if redirect_mode=='external' else 'redirect limit exceeded') in out,out
redirect_mode=None
print('PASS cross-origin redirects and redirect loops rejected before writes')
reset()
quiet_log=Path(tmp)/'quiet.log'
with conf.open('a') as f: f.write(f"QUIET_LOG_FILE='{quiet_log}'\n")
request_gate=threading.Event()
p=subprocess.run(['bash',SCRIPT,'--config',str(conf),'--apply-quiet','--no-git'],capture_output=True,text=True,timeout=5)
assert p.returncode==0 and not p.stdout and not p.stderr,p
assert not writes, 'launcher must return before blocked background work completes'
request_gate.set()
request_gate=None
deadline=time.monotonic()+60
while time.monotonic()<deadline:
if quiet_log.exists() and 'Background worker finished:' in quiet_log.read_text(): break
time.sleep(.1)
out=quiet_log.read_text()
assert 'exit_status=0' in out and 'succeeded=4' in out,out
assert 'Progress:' in out,out
assert {n for n,_,_,_ in writes}=={1,2,3,8}
assert quiet_log.stat().st_mode & 0o777 == 0o600
print('PASS quiet mode returns immediately, writes no terminal output, applies all repositories and logs completion')
reset(); failure_mode='auth'; fail_repo=1
quiet_log.write_text('')
p=subprocess.run(['bash',SCRIPT,'--config',str(conf),'--apply-quiet'],capture_output=True,text=True,timeout=5)
assert p.returncode==0 and not p.stdout and not p.stderr
deadline=time.monotonic()+30
while time.monotonic()<deadline:
if 'Background worker finished:' in quiet_log.read_text(): break
time.sleep(.1)
assert 'exit_status=1' in quiet_log.read_text() and not writes
print('PASS quiet background failure is retained in the log')
badconf=Path(tmp)/'bad-config'
badconf.write_text(conf.read_text()+f"QUIET_LOG_FILE='{tmp}/missing/log'\n")
p=subprocess.run(['bash',SCRIPT,'--config',str(badconf),'--apply-quiet'],capture_output=True,text=True)
assert p.returncode!=0 and not p.stdout and not p.stderr
print('PASS inaccessible quiet log fails silently before launching')
server.shutdown()