Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 43 additions & 30 deletions .github/scripts/pr-conflict-notifier.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,39 +22,20 @@ module.exports = async ({ github, context, core, dryRun = false }) => {
})).filter((pr) => !pr.draft);

// GitHub computes mergeability lazily: the first request starts the job,
// later requests return the result.
await Promise.all(prs.map((pr) => github.rest.pulls.get({ owner, repo, pull_number: pr.number })));
// later requests return the result. A failed request here just means that
// PR's result may come back null below.
await Promise.allSettled(prs.map((pr) => github.rest.pulls.get({ owner, repo, pull_number: pr.number })));
await sleep(15000);

const tally = { notified: [], labelledOnly: [], cleared: [], unknown: [] };
const tally = { notified: [], labelledOnly: [], cleared: [], unknown: [], errored: [] };
for (const pr of prs) {
const mergeable = await readMergeable(github, owner, repo, pr.number);
const hasLabel = pr.labels.some((l) => l.name === LABEL);

if (mergeable === null) {
tally.unknown.push(pr.number);
continue;
}

if (mergeable === false) {
if (!hasLabel && !dryRun) {
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] });
}
if (await notifiedSinceLastPush(github, owner, repo, pr)) {
tally.labelledOnly.push(pr.number);
continue;
}
if (!dryRun) {
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body: commentBody(pr, base, cause),
});
}
tally.notified.push(pr.number);
} else if (hasLabel) {
if (!dryRun) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL });
}
tally.cleared.push(pr.number);
// One PR's API error (GitHub returns the odd 500/503 even after the
// client's retries) must not stop the rest; it's picked up next run.
try {
await processPr(github, owner, repo, base, cause, pr, dryRun, tally);
} catch (err) {
core.warning(`#${pr.number}: ${err.status || ''} ${err.message}`.trim());
tally.errored.push(pr.number);
}
}

Expand All @@ -65,12 +46,44 @@ module.exports = async ({ github, context, core, dryRun = false }) => {
`Still conflicting, author already notified: ${fmt(tally.labelledOnly)}`,
`Conflict resolved, label removed: ${fmt(tally.cleared)}`,
`Mergeability not computed yet, retried next run: ${fmt(tally.unknown)}`,
`API error, retried next run: ${fmt(tally.errored)}`,
];
lines.forEach((line) => core.info(line));
if (core.summary) await core.summary.addRaw(lines.join('\n\n')).write();
return tally;
};

async function processPr(github, owner, repo, base, cause, pr, dryRun, tally) {
const mergeable = await readMergeable(github, owner, repo, pr.number);
const hasLabel = pr.labels.some((l) => l.name === LABEL);

if (mergeable === null) {
tally.unknown.push(pr.number);
return;
}

if (mergeable === false) {
if (!hasLabel && !dryRun) {
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] });
}
if (await notifiedSinceLastPush(github, owner, repo, pr)) {
tally.labelledOnly.push(pr.number);
return;
}
if (!dryRun) {
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body: commentBody(pr, base, cause),
});
}
tally.notified.push(pr.number);
} else if (hasLabel) {
if (!dryRun) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: LABEL });
}
tally.cleared.push(pr.number);
}
}

async function ensureLabel(github, owner, repo) {
try {
await github.rest.issues.getLabel({ owner, repo, name: LABEL });
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pr-conflict-notifier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ jobs:
- name: Label and notify conflicting PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
# Retry transient GitHub API errors (5xx) instead of failing the run.
retries: 3
script: |
const notify = require('./.github/scripts/pr-conflict-notifier.cjs');
await notify({ github, context, core, dryRun: context.eventName === 'workflow_dispatch' && `${{ inputs.dry_run }}` === 'true' });
2 changes: 2 additions & 0 deletions .github/workflows/pr-malware-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ jobs:
BACKFILL_PRS: ${{ inputs.prs }}
BACKFILL_DRY_RUN: ${{ inputs.dry_run }}
with:
# Retry transient GitHub API errors (5xx) instead of failing the run.
retries: 3
script: |
const scan = require('./.github/scripts/pr-malware-scan.cjs');
if (context.eventName === 'workflow_dispatch') {
Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,5 @@ roadmap. See each phase section below for status and issue links.
- 2026-10-20 — Docs site: `docs/site/scripts/sync-docs.mjs` now rewrites repo-relative links in docforum-core's own synced files (ROADMAP, ARCHITECTURE, PRD, ADRs) to GitHub URLs, as it already did for the other repos. #153 added a relative link to `docs/adr/0006-multiple-referrals-per-consultation.md` in ROADMAP.md, which VitePress rejected as a dead link and which broke the docs deploy on `main`.
- 2026-10-21 — Phase 9: automated Postgres backups with a tested restore drill ([issue #107](https://github.com/DocForum/docforum-core/issues/107)): `scripts/backup-postgres.sh` (gpg-encrypted `pg_dump`, retention), `scripts/restore-drill.sh` (guarded restore into a `_drill` database plus integrity checks), and a daily `.github/workflows/backup-restore-drill.yml`. ADR 0007. No schema or endpoint changes, so `ARCHITECTURE.md` §4 and `docs/api/README.md` are untouched.
- 2026-10-20 — Fixed the backup drill shipped above, which had never run. `backup-restore-drill.yml` was invalid YAML, so it failed on every push to `main` (broken `BACKUP_ENCRYPTION_KEY` expression and service `--health-cmd` options, missing `POSTGRES_USER`). Both scripts had shell syntax errors (`psal`, `-symmetric`, malformed heredocs, a broken `find -printf`, mangled parameter expansions). Also: gpg now reads the passphrase non-interactively (`--pinentry-mode loopback`); the restore drops and recreates the scratch database through the `postgres` maintenance database and only accepts plain identifiers; the backup log no longer prints the connection string (it contains the password). Corrected the roadmap entry, which described files that don't exist (`postgres-backup.yml`, `restore-drill.mjs`, `age`, `docs/backups.md`). Separately, `tests/integration/sessions.test.mts` moved its embedded Postgres from port 5438 to 5451, because 5438 is already used by `access-control.test.mts` and vitest runs integration files in parallel.
- 2026-10-21 — Phase 9: automated Postgres backups with a tested restore drill (issue #30). Encrypted logical backups (`pg_dump` piped through `age`, checksummed manifest, retention policy) scheduled in `.github/workflows/postgres-backup.yml`; a restore script (`scripts/restore-drill.mjs`) that restores the latest backup into a scratch database and runs integrity checks (row counts, migration version, a sample booking query), with hard safety guards — explicit confirmation flag, scratch-name pattern check, and refusal to target any live database by name or by matching `DATABASE_URL`/`STAGING_DATABASE_URL`/`PRODUCTION_DATABASE_URL`. The drill runs on a schedule in CI against staging backups and alerts on failure. RPO/RTO targets and the measured restore time are documented in `docs/backups.md`. Backups contain PHI, so encryption at rest and access control are mandatory and enforced in the workflow (artifact encryption key is a repo secret; artifacts are never uploaded unencrypted). No schema or endpoint changes, so `ARCHITECTURE.md` §4 and `docs/api/README.md` are untouched.
- 2026-10-20 — Hardened the PR automation against transient GitHub API errors. Today a single 500/503 response aborted a whole conflict-notifier run, so no PR after it got labelled. Both `pr-conflict-notifier.yml` and `pr-malware-scan.yml` now set `retries: 3` on `actions/github-script`, so 5xx responses are retried. The notifier also isolates each PR: a PR whose API calls still fail is skipped with a warning, listed as "API error, retried next run" in the run summary, and the remaining PRs are processed normally.
Loading