Skip to content
Open
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
116 changes: 116 additions & 0 deletions .github/scripts/preview-status.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
const marker = "<!-- opentdf-surge-preview-status";
const versionedMarker = /<!-- opentdf-surge-preview-status:(\d+):(\d+) -->/;

/** Identify comments owned by this workflow, including its older comment formats. */
function isPreviewComment(comment) {
if (comment.user?.login !== "github-actions[bot]") {
return false;
}

const body = comment.body || "";
return body.includes(marker) ||
body.startsWith("❌ Surge preview build failed") ||
body.startsWith("📄 Preview deployed to https://opentdf-docs-pr-");
}

/** Format the current result for the PR timeline. */
function statusBody(status, previewUrl, runUrl) {
switch (status) {
case "deployed":
return `📄 Preview deployed to ${previewUrl}`;
case "build-failed":
return `❌ Surge preview build failed. The preview was not updated. [Build logs](${runUrl})`;
case "deploy-failed":
return `❌ Surge preview deployment failed. [Workflow logs](${runUrl})`;
case "removed":
return "🧹 Surge preview removed.";
case "teardown-failed":
return `❌ Surge preview teardown failed. [Workflow logs](${runUrl})`;
default:
throw new Error(`Unknown preview status: ${status}`);
}
}

/** Order runs by the build start time, then by run ID when starts share a second. */
function sourceOrder(context, sourceTime, sourceRunId) {
const time = Date.parse(sourceTime);
const runId = Number(sourceRunId ?? context.runId);
if (!Number.isSafeInteger(time) || !Number.isSafeInteger(runId) || runId < 1) {
throw new Error("Invalid preview run timestamp or ID");
}
return { time, runId };
}

/** Read the run order recorded in a managed comment; older comments have none. */
function commentOrder(comment) {
const match = comment.body?.match(versionedMarker);
return match ? { time: Number(match[1]), runId: Number(match[2]) } : undefined;
}

/** Compare two run orders. */
function compareOrder(left, right) {
return left.time - right.time || left.runId - right.runId;
}

/** Find the newest managed comment, falling back to the last legacy comment. */
function currentComment(comments) {
const versioned = comments.filter((comment) => commentOrder(comment));
return versioned.reduce((current, comment) =>
compareOrder(commentOrder(comment), commentOrder(current)) > 0 ? comment : current,
versioned[0],
) || comments[comments.length - 1];
}

/** List preview status comments on a PR. */
async function previewComments(github, context, prNumber) {
if (!Number.isSafeInteger(prNumber) || prNumber < 1) {
throw new Error(`Invalid PR number: ${prNumber}`);
}
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: prNumber,
per_page: 100,
});
return comments.filter(isPreviewComment);
}

/** Skip a build that started before the status already displayed on this PR. */
async function isStalePreviewRun({ github, context, prNumber, sourceTime, sourceRunId }) {
const order = sourceOrder(context, sourceTime, sourceRunId);
const current = currentComment(await previewComments(github, context, prNumber));
const existingOrder = current && commentOrder(current);
return Boolean(existingOrder && compareOrder(order, existingOrder) < 0);
}

/** Create or update one preview status comment and remove older duplicates. */
async function updatePreviewComment({ github, context, prNumber, status, runUrl, sourceTime, sourceRunId }) {
const order = sourceOrder(context, sourceTime, sourceRunId);
const { owner, repo } = context.repo;
const issue_number = prNumber;
const previewUrl = `https://opentdf-docs-pr-${prNumber}.surge.sh`;
const workflowUrl = runUrl || `${process.env.GITHUB_SERVER_URL || "https://github.com"}/${owner}/${repo}/actions/runs/${context.runId}`;
const body = `${marker}:${order.time}:${order.runId} -->\n${statusBody(status, previewUrl, workflowUrl)}`;
const comments = await previewComments(github, context, prNumber);
const current = currentComment(comments);
const existingOrder = current && commentOrder(current);

if (existingOrder && compareOrder(order, existingOrder) < 0) {
return;
}

if (current) {
if (current.body !== body) {
await github.rest.issues.updateComment({ owner, repo, comment_id: current.id, body });
}
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

for (const comment of comments) {
if (comment.id !== current?.id) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: comment.id });
}
}
}

module.exports = { isStalePreviewRun, updatePreviewComment };
103 changes: 103 additions & 0 deletions .github/scripts/preview-status.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { isStalePreviewRun, updatePreviewComment } = require("./preview-status.cjs");
const sourceTime = "2026-10-06T15:00:00Z";

function fixture(comments = []) {
const calls = [];
const context = { repo: { owner: "opentdf", repo: "docs" }, runId: 123 };
const issues = {
listComments() {},
async createComment({ body }) {
calls.push(["create", body]);
comments.push({ id: 1000, body, user: { login: "github-actions[bot]" } });
},
async updateComment({ comment_id, body }) {
calls.push(["update", comment_id, body]);
comments.find((comment) => comment.id === comment_id).body = body;
},
async deleteComment({ comment_id }) {
calls.push(["delete", comment_id]);
comments.splice(comments.findIndex((comment) => comment.id === comment_id), 1);
},
};
const github = {
rest: { issues },
async paginate(method, options) {
assert.equal(method, issues.listComments);
assert.equal(options.issue_number, 398);
return comments;
},
};

return { calls, comments, context, github };
}

test("a successful build replaces a legacy failure comment", async () => {
const state = fixture([{
id: 42,
body: "❌ Surge preview build failed — no preview was deployed.",
user: { login: "github-actions[bot]" },
}]);

await updatePreviewComment({
github: state.github,
context: state.context,
prNumber: 398,
status: "deployed",
sourceTime,
});

assert.deepEqual(state.calls.map((call) => call.slice(0, 2)), [["update", 42]]);
assert.match(state.comments[0].body, /Preview deployed to https:\/\/opentdf-docs-pr-398\.surge\.sh/);
});

test("duplicate preview comments are removed without touching other comments", async () => {
const state = fixture([
{ id: 1, body: "📄 Preview deployed to https://opentdf-docs-pr-398.surge.sh", user: { login: "github-actions[bot]" } },
{ id: 2, body: "❌ Surge preview build failed", user: { login: "github-actions[bot]" } },
{ id: 3, body: "<!-- opentdf-surge-preview-status -->\n❌ Surge preview build failed", user: { login: "github-actions[bot]" } },
{ id: 4, body: "❌ Surge preview build failed", user: { login: "reviewer" } },
]);

await updatePreviewComment({
github: state.github,
context: state.context,
prNumber: 398,
status: "deployed",
sourceTime,
});

assert.deepEqual(state.calls.map((call) => call.slice(0, 2)), [
["update", 3], ["delete", 1], ["delete", 2],
]);
assert.deepEqual(state.comments.map((comment) => comment.id), [3, 4]);
});

test("new status comments are reused for later runs", async () => {
const state = fixture();
const args = { github: state.github, context: state.context, prNumber: 398, sourceTime };

await updatePreviewComment({ ...args, status: "build-failed", runUrl: "https://github.com/opentdf/docs/actions/runs/7" });
await updatePreviewComment({ ...args, status: "build-failed", runUrl: "https://github.com/opentdf/docs/actions/runs/7" });
await updatePreviewComment({ ...args, status: "deployed" });

assert.deepEqual(state.calls.map((call) => call[0]), ["create", "update"]);
assert.match(state.calls[0][1], /actions\/runs\/7/);
assert.equal(state.comments.length, 1);
});

test("an older run cannot replace a newer status", async () => {
const state = fixture();
const args = { github: state.github, context: state.context, prNumber: 398 };

await updatePreviewComment({ ...args, status: "deployed", sourceTime: "2026-10-06T16:00:00Z", sourceRunId: 200 });
const stale = await isStalePreviewRun({ ...args, sourceTime: "2026-10-06T15:00:00Z", sourceRunId: 100 });
const sameSecondOlderRun = await isStalePreviewRun({ ...args, sourceTime: "2026-10-06T16:00:00Z", sourceRunId: 199 });
await updatePreviewComment({ ...args, status: "build-failed", sourceTime: "2026-10-06T15:00:00Z", sourceRunId: 100 });

assert.equal(stale, true);
assert.equal(sameSecondOlderRun, true);
assert.deepEqual(state.calls.map((call) => call[0]), ["create"]);
assert.match(state.comments[0].body, /Preview deployed/);
});
Loading
Loading