What's new
fix: prevent duplicate issues & PRs on retry-after-deadlock + Link-header pagination (#296)
Two compounding bugs in mirrorGitRepoIssuesToGitea and mirrorGitRepoPullRequestsToGitea could produce duplicate Gitea issue / PR-as-issue rows on every sync against any non-SQLite Gitea backend.
Bug 1 — short-page pagination heuristic terminated early. The existing-issues, existing-PRs, and per-issue-comments pre-fetches all paginated with limit=100 and broke when pageX.length < itemsPerPage. But Gitea caps response size at [api].MAX_RESPONSE_ITEMS (default 50), so the very first page already looked "short" and pagination terminated after one page. Every existing item past page 1 was then misclassified as new on every sync and re-created. Switched to RFC 5988 Link: rel="next" — applied symmetrically to the issues pre-fetch, the PR pre-fetch, and the per-issue comments fetch.
Bug 2 — retry-after-deadlock created duplicates. When Gitea's CreateIssue handler commits the issue insert in one transaction and then deadlocks on the subsequent addLabel / repository counter update in a second transaction, the row is committed and visible — but the in-memory dedup map (built once at function entry) is never refreshed between retries. processWithRetry re-invokes the per-item callback, sees the stale map, and creates a fresh duplicate via httpPost. Reproduces deterministically on MySQL (Error 1213 / 40001) and PostgreSQL (40P01); SQLite escapes because writes serialize globally. Fixed by defensively re-querying Gitea by marker ([GH-ISSUE #N] / [PR #N]) before create, and caching successful creates into the map immediately.
Real-world impact on a Gitea 1.26.2 / PostgreSQL 17.10 backend: +3,345 duplicates overnight on stock v3.16.0 → 0 with patch.
fix: resume interrupted jobs after startup, not only at boot (#297)
src/middleware.ts previously gated recovery behind two module-level booleans (recoveryInitialized + recoveryAttempted) that both latched true on the first request. After that, if (!recoveryInitialized && !recoveryAttempted) was permanently false, so any job that got interrupted after boot (deadlock retry hitting maxRetries, network blip, container restart of a dependency) never got resumed — findInterruptedJobs kept detecting it on every health poll and logging Found 1 interrupted jobs:, but the resumer never re-fired.
Replaced the one-shot gate with recoveryInFlight released in a finally block (real per-process mutex). Actual throttling is delegated to the existing 5-minute skipIfRecentAttempt check inside initializeRecovery(), which is the right place for it.
Also fixed a secondary log-spam issue: findInterruptedJobs was logging unconditionally on every call, including from passive callers (hasJobsNeedingRecovery from the health endpoint and middleware). Made per-job logging opt-in via { logFound: true }, which the active recovery cycle in initializeRecovery() now passes explicitly so operators still see which jobs are being worked on.
Thanks to first-time contributor @seanmousseau for both PRs.