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
159 changes: 27 additions & 132 deletions .github/scripts/bridge-public-pr-to-monorepo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,14 @@ import { pathToFileURL } from 'node:url';
// - public/agents/.github/scripts/bridge-public-pr-to-monorepo.mjs
// - public/open-knowledge/.github/scripts/bridge-public-pr-to-monorepo.mjs
// - public/mermaid-wysiwyg/.github/scripts/bridge-public-pr-to-monorepo.mjs
const BRIDGE_COMMENT_MARKER = '<!-- monorepo-pr-bridge -->';
const OSS_SYNC_BOT_NAME = 'inkeep-oss-sync[bot]';
const OSS_SYNC_BOT_EMAIL = '274976938+inkeep-oss-sync[bot]@users.noreply.github.com';

// Strip x-access-token credentials from any string that might end up in an
// error message, log line, or thrown exception. GitHub Actions masks repo
// secrets in its own job log, but this script's exceptions can also surface
// in PR comments (e.g. the failed-state path below includes `error.message`
// in `Patch application failed`), failure-alert issues, or future error-
// reporting integrations — none of which inherit the Actions log mask.
// Defense-in-depth: redact at the boundary.
// secrets in its own job log, but this script's exceptions can also surface in
// failure-alert issues or future error-reporting integrations — none of which
// inherit the Actions log mask. Defense-in-depth: redact at the boundary.
function sanitizeErrorMessage(value) {
if (typeof value !== 'string') return value;
return value.replace(/https:\/\/x-access-token:[^@\s]+@/g, 'https://x-access-token:***@');
Expand Down Expand Up @@ -473,70 +470,17 @@ ${buildBridgeMetadata(publicPr, mirrorPath)}`;
return body;
}

function buildPublicComment({ publicPr, status, details }) {
if (status === 'synced') {
return `${BRIDGE_COMMENT_MARKER}
Thanks for the contribution! A maintainer will review and merge your PR. Your commit attribution is preserved as @${publicPr.user.login}.
function buildWelcomePublicComment() {
return `Thanks for the contribution!

**What happens next:**

- A maintainer will review your PR.
- If you don't hear back within a few business days, please comment here to nudge — that's the right thing to do, not annoying.
- When your change is accepted, this PR closes automatically. Don't be alarmed when it closes — that's how it merges, and your authorship is preserved.

This comment will be updated as the status changes.`;
}

if (status === 'no-op') {
return `${BRIDGE_COMMENT_MARKER}
I checked this PR, but there was no new change to sync.

${details}`;
}

if (status === 'closed') {
return `${BRIDGE_COMMENT_MARKER}
This PR was closed without merging.

${details}`;
}

if (status === 'merged-upstream') {
return `${BRIDGE_COMMENT_MARKER}
This PR was merged directly here. A maintainer will make sure the change is reconciled on our side.

${details}`;
}

return `${BRIDGE_COMMENT_MARKER}
I could not sync this PR automatically. A maintainer will look into it.

${details}`;
- If you don't hear back within a few business days, please comment here to nudge our team.
- This repository is maintained through an internal mirror. When your change is accepted, this PR will close automatically. Don't be alarmed when it closes — that's how it merges, and your authorship is preserved.`;
}

async function upsertIssueComment({ token, repo, issueNumber, body }) {
let existing = null;
let page = 1;
while (!existing) {
const comments = await githubRequest({
token,
path: `/repos/${repo}/issues/${issueNumber}/comments?per_page=100&page=${page}`,
});
if (comments.length === 0) break;
existing = comments.find((comment) => comment.body?.includes(BRIDGE_COMMENT_MARKER));
if (!existing && comments.length < 100) break;
page++;
}
if (existing) {
await githubRequest({
token,
method: 'PATCH',
path: `/repos/${repo}/issues/comments/${existing.id}`,
body: { body },
});
return existing.html_url;
}

async function createIssueComment({ token, repo, issueNumber, body }) {
const created = await githubRequest({
token,
method: 'POST',
Expand All @@ -546,6 +490,19 @@ async function upsertIssueComment({ token, repo, issueNumber, body }) {
return created.html_url;
}

async function acknowledgePublicPr() {
const publicToken = requireEnv('PUBLIC_TOKEN');
const publicRepo = requireEnv('PUBLIC_REPO');
const publicPrNumber = Number.parseInt(requireEnv('PUBLIC_PR_NUMBER'), 10);

await createIssueComment({
token: publicToken,
repo: publicRepo,
issueNumber: publicPrNumber,
body: buildWelcomePublicComment(),
});
}

async function findOpenInternalPr({ token, repo, owner, branchName }) {
const pulls = await githubRequest({
token,
Expand Down Expand Up @@ -727,20 +684,6 @@ async function syncPublicPr() {
const patch = filterDiffByPath(rawPatch, excludedPrefixes);

if (!patch.trim()) {
const details =
rawPatch.trim() && excludedPrefixes.length > 0
? `Every diff section matched an excluded path prefix (\`${excludedPrefixes.join('`, `')}\`), so there was nothing left to port.`
: 'GitHub returned an empty patch, so there was nothing to port.';
await upsertIssueComment({
token: publicToken,
repo: publicRepo,
issueNumber: publicPrNumber,
body: buildPublicComment({
publicPr,
status: 'no-op',
details,
}),
});
return;
}

Expand All @@ -752,16 +695,6 @@ async function syncPublicPr() {
try {
run('git', ['-C', internalRepoDir, 'apply', '--index', '--3way', patchFile]);
} catch (error) {
await upsertIssueComment({
token: publicToken,
repo: publicRepo,
issueNumber: publicPrNumber,
body: buildPublicComment({
publicPr,
status: 'failed',
details: `Patch application failed.\n\n\`\`\`\n${error.message}\n\`\`\``,
}),
});
throw error;
}

Expand Down Expand Up @@ -847,16 +780,6 @@ async function syncPublicPr() {
});

if (!internalPr && !hasStagedChanges) {
await upsertIssueComment({
token: publicToken,
repo: publicRepo,
issueNumber: publicPrNumber,
body: buildPublicComment({
publicPr,
status: 'no-op',
details: 'The change already appears to be present, so there was nothing new to sync.',
}),
});
return;
}
}
Expand Down Expand Up @@ -890,17 +813,6 @@ async function syncPublicPr() {
},
});
}

await upsertIssueComment({
token: publicToken,
repo: publicRepo,
issueNumber: publicPrNumber,
body: buildPublicComment({
publicPr,
internalPr,
status: 'synced',
}),
});
}

async function closeLinkedInternalPr() {
Expand Down Expand Up @@ -930,17 +842,6 @@ async function closeLinkedInternalPr() {
}

if (publicPr.merged_at) {
await upsertIssueComment({
token: publicToken,
repo: publicRepo,
issueNumber: publicPrNumber,
body: buildPublicComment({
publicPr,
internalPr,
status: 'merged-upstream',
details: '',
}),
});
return;
}

Expand Down Expand Up @@ -969,21 +870,15 @@ async function closeLinkedInternalPr() {
} catch (error) {
console.log(`Branch cleanup skipped: ${error.message}`);
}

await upsertIssueComment({
token: publicToken,
repo: publicRepo,
issueNumber: publicPrNumber,
body: buildPublicComment({
publicPr,
status: 'closed',
details: '',
}),
});
}

async function main() {
const mode = process.argv[2];
if (mode === 'acknowledge') {
await acknowledgePublicPr();
return;
}

if (mode === 'sync') {
await syncPublicPr();
return;
Expand All @@ -1007,7 +902,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
export {
buildCommitAttribution,
buildInternalPrBody,
buildPublicComment,
buildWelcomePublicComment,
listPublicPrCommits,
prefixPatchPaths,
};
25 changes: 25 additions & 0 deletions .github/workflows/monorepo-pr-bridge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,31 @@ concurrency:
cancel-in-progress: true

jobs:
acknowledge:
# Public-facing acknowledgement should happen immediately on a new PR and
# stay decoupled from the inkeep-oss-sync environment approval that imports
# the PR into agents-private.
if: github.event.action == 'opened' && github.event.pull_request.head.ref != 'copybara/sync'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout public repo automation
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 1

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22

- name: Acknowledge public PR
env:
PUBLIC_PR_NUMBER: ${{ github.event.pull_request.number }}
PUBLIC_REPO: ${{ github.repository }}
PUBLIC_TOKEN: ${{ github.token }}
run: node .github/scripts/bridge-public-pr-to-monorepo.mjs acknowledge

sync:
# Pause before minting the agents-private App token or writing any internal
# branch/PR. The environment has required reviewers configured in the public
Expand Down