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
40 changes: 31 additions & 9 deletions setup/js/add_labels.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ const main = createCountGatedHandler({
throw new Error(`Failed to resolve GraphQL node ID for ${contextType} #${itemNumber}`);
}

// Detect whether the item is a pull request. The REST issues endpoint returns a
// `pull_request` field for PRs, and PR node IDs start with "PR_". The GraphQL
// updateIssue mutation only accepts Issue node IDs; PRs must use updatePullRequest.
const itemIsPR = Boolean(issueData?.pull_request) || issueNodeId.startsWith("PR_");

const repoLabels = await fetchAllRepoLabels(githubClient, repoParts.owner, repoParts.repo);
const labelIdByName = new Map(repoLabels.map(label => [label.name.toLowerCase(), label.id]));

Expand All @@ -290,9 +295,27 @@ const main = createCountGatedHandler({
const labelIntentUpdates = buildIssueIntentLabelUpdates(mergedSpecs, labelIdByName);

core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo} via GraphQL intent mutation`);
const result = await withRetry(
() =>
githubClient.graphql(
// Both updateIssue and updatePullRequest use LabelUpdateInput (rationale/confidence/suggest),
// which is gated by the "update_issue_suggestions" GraphQL feature flag.
const intentHeaders = { "GraphQL-Features": "update_issue_suggestions" };
const [mutationQuery, mutationVars, getResultLabels] = itemIsPR
? [
`mutation($prId: ID!, $labels: [LabelUpdateInput!]!) {
updatePullRequest(input: { pullRequestId: $prId, labels: $labels }) {
pullRequest {
id
labels(first: 100) {
nodes {
name
}
}
}
}
}`,
{ prId: issueNodeId, labels: labelIntentUpdates, headers: intentHeaders },
r => r?.updatePullRequest?.pullRequest?.labels?.nodes,
]
: [
`mutation($issueId: ID!, $labels: [LabelUpdateInput!]!) {
updateIssue(input: { id: $issueId, labels: $labels }) {
issue {
Expand All @@ -305,14 +328,13 @@ const main = createCountGatedHandler({
}
}
}`,
{ issueId: issueNodeId, labels: labelIntentUpdates, headers: { "GraphQL-Features": "update_issue_suggestions" } }
),
RATE_LIMIT_RETRY_CONFIG,
`add_labels to ${contextType} #${itemNumber} in ${itemRepo}`
);
{ issueId: issueNodeId, labels: labelIntentUpdates, headers: intentHeaders },
r => r?.updateIssue?.issue?.labels?.nodes,
];
const result = await withRetry(() => githubClient.graphql(mutationQuery, mutationVars), RATE_LIMIT_RETRY_CONFIG, `add_labels to ${contextType} #${itemNumber} in ${itemRepo}`);

core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}`);
const afterLabels = result?.updateIssue?.issue?.labels?.nodes || [];
const afterLabels = getResultLabels(result) || [];
return attachExecutionState(
{
success: true,
Expand Down
112 changes: 66 additions & 46 deletions setup/js/artifact_client.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -368,14 +368,23 @@ class DefaultArtifactClient {
const zipLike = isZipResponse(location, contentType);
if (zipLike && !options.skipDecompress) {
ensureUnzipAvailable();
const tempZip = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-artifact-download-")), "artifact.zip");
digest = await streamToFile(blobResponse, tempZip);
const unzipResult = spawnSync("unzip", ["-q", tempZip, "-d", destination], { encoding: "utf8" });
if (unzipResult.error) {
throw unzipResult.error;
}
if (unzipResult.status !== 0) {
throw new Error(`unzip failed: ${unzipResult.stderr || unzipResult.stdout || "unknown error"}`);
const tempDownloadDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-artifact-download-"));
const tempZip = path.join(tempDownloadDir, "artifact.zip");
try {
digest = await streamToFile(blobResponse, tempZip);
const unzipResult = spawnSync("unzip", ["-q", tempZip, "-d", destination], { encoding: "utf8" });
if (unzipResult.error) {
throw unzipResult.error;
}
if (unzipResult.status !== 0) {
throw new Error(`unzip failed: ${unzipResult.stderr || unzipResult.stdout || "unknown error"}`);
}
} finally {
try {
fs.rmSync(tempDownloadDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors — best effort only.
}
}
} else {
const fileName = parseFilenameFromContentDisposition(blobResponse.headers.get("content-disposition") || "");
Expand All @@ -398,6 +407,7 @@ class DefaultArtifactClient {
let artifactName = String(name || "").trim();
let uploadPath = "";
let contentType = "application/zip";
let tmpDir = "";

if (options.skipArchive) {
if (files.length !== 1) {
Expand All @@ -406,52 +416,62 @@ class DefaultArtifactClient {
uploadPath = files[0];
contentType = "application/octet-stream";
} else {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-artifact-upload-"));
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-artifact-upload-"));
uploadPath = path.join(tmpDir, `${artifactName || "artifact"}.zip`);
createZipFromFiles(files, rootDirectory, uploadPath);
Comment on lines +419 to 421
}

const { workflowRunBackendId, workflowJobRunBackendId } = getBackendIdsFromRuntimeToken();
const createRequest = {
workflowRunBackendId,
workflowJobRunBackendId,
name: artifactName,
version: 7,
mimeType: contentType,
};
const expiresAt = formatRetentionTimestamp(options.retentionDays);
if (expiresAt) {
createRequest.expiresAt = expiresAt;
}
try {
const { workflowRunBackendId, workflowJobRunBackendId } = getBackendIdsFromRuntimeToken();
const createRequest = {
workflowRunBackendId,
workflowJobRunBackendId,
name: artifactName,
version: 7,
mimeType: contentType,
};
const expiresAt = formatRetentionTimestamp(options.retentionDays);
if (expiresAt) {
createRequest.expiresAt = expiresAt;
}

/** @type {any} */
const createResponse = await twirpRequest("CreateArtifact", createRequest);
const signedUploadUrl = createResponse?.signedUploadUrl || createResponse?.signed_upload_url;
if (!createResponse?.ok || !signedUploadUrl) {
throw new Error("CreateArtifact returned an invalid response");
}
/** @type {any} */
const createResponse = await twirpRequest("CreateArtifact", createRequest);
const signedUploadUrl = createResponse?.signedUploadUrl || createResponse?.signed_upload_url;
if (!createResponse?.ok || !signedUploadUrl) {
throw new Error("CreateArtifact returned an invalid response");
}

const uploadSize = await uploadFileToSignedURL(uploadPath, signedUploadUrl, contentType);
const sha256 = await hashFile(uploadPath);
const uploadSize = await uploadFileToSignedURL(uploadPath, signedUploadUrl, contentType);
const sha256 = await hashFile(uploadPath);

const finalizeRequest = {
workflowRunBackendId,
workflowJobRunBackendId,
name: artifactName,
size: String(uploadSize),
hash: `sha256:${sha256}`,
};
/** @type {any} */
const finalizeResponse = await twirpRequest("FinalizeArtifact", finalizeRequest);
if (!finalizeResponse?.ok) {
throw new Error("FinalizeArtifact returned an invalid response");
}
const finalizeRequest = {
workflowRunBackendId,
workflowJobRunBackendId,
name: artifactName,
size: String(uploadSize),
hash: `sha256:${sha256}`,
};
/** @type {any} */
const finalizeResponse = await twirpRequest("FinalizeArtifact", finalizeRequest);
if (!finalizeResponse?.ok) {
throw new Error("FinalizeArtifact returned an invalid response");
}

return {
id: Number(finalizeResponse.artifactId ?? finalizeResponse.artifact_id ?? 0) || undefined,
size: uploadSize,
digest: sha256,
};
return {
id: Number(finalizeResponse.artifactId ?? finalizeResponse.artifact_id ?? 0) || undefined,
size: uploadSize,
digest: sha256,
};
} finally {
if (tmpDir) {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors — best effort only.
}
}
}
}
}

Expand Down
8 changes: 4 additions & 4 deletions setup/js/check_command_position.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ async function main() {
return;
}

// Normalize whitespace and resolve the matched slash command at the start of the text.
const trimmedText = text.trim();
const matchedCommand = resolveMatchedCommand(trimmedText, commands);
const firstWord = trimmedText.split(/\s+/)[0];
// Resolve the matched slash command at the start of the text.
// Commands must appear at position zero to match the compile-time activation conditions.
const matchedCommand = resolveMatchedCommand(text, commands);
const firstWord = text.trimStart().split(/\s+/)[0];

core.info(`Checking command position. First word in text: ${firstWord}`);
core.info(`Looking for commands: ${commands.map(c => `/${c}`).join(", ")}`);
Expand Down
137 changes: 37 additions & 100 deletions setup/js/close_expired_discussions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,10 @@
// <reference types="@actions/github-script" />

const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs");
const { generateExpiredEntityFooter, getExpiredEntityCautionAlert } = require("./generate_footer.cjs");
const { formatDateInProjectTimeZone } = require("./project_timezone.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { addDiscussionComment, closeDiscussionAsOutdated, createClosedRecord, createExpiredEntityHandler } = require("./expired_entity_handler_factory.cjs");
const { getWorkflowMetadata } = require("./workflow_metadata_helpers.cjs");
const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs");

/**
* Add comment to a GitHub Discussion using GraphQL
* @param {any} github - GitHub GraphQL instance
* @param {string} discussionId - Discussion node ID
* @param {string} message - Comment body
* @returns {Promise<{id: string, url: string}>} Comment details
*/
async function addDiscussionComment(github, discussionId, message) {
const result = await github.graphql(
`
mutation($dId: ID!, $body: String!) {
addDiscussionComment(input: { discussionId: $dId, body: $body }) {
comment {
id
url
}
}
}`,
{ dId: discussionId, body: sanitizeContent(message) }
);

return result.addDiscussionComment.comment;
}

/**
* Close a GitHub Discussion as OUTDATED using GraphQL
* @param {any} github - GitHub GraphQL instance
* @param {string} discussionId - Discussion node ID
* @returns {Promise<{id: string, url: string}>} Discussion details
*/
async function closeDiscussionAsOutdated(github, discussionId) {
const result = await github.graphql(
`
mutation($dId: ID!) {
closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) {
discussion {
id
url
}
}
}`,
{ dId: discussionId }
);

return result.closeDiscussion.discussion;
}

/**
* Check if a discussion already has an expiration comment and fetch its closed state
* @param {any} github - GitHub GraphQL instance
Expand Down Expand Up @@ -106,60 +57,46 @@ async function main() {
summaryHeading: "Expired Discussions Cleanup",
enableDedupe: true, // Discussions may have duplicates across pages
includeSkippedHeading: true,
processEntity: async discussion => {
core.info(` Checking for existing expiration comment and closed state on discussion #${discussion.number}`);
const { hasComment, isClosed } = await hasExpirationComment(github, discussion.id);

if (isClosed) {
core.warning(` Discussion #${discussion.number} is already closed, skipping`);
return {
status: "skipped",
record: {
number: discussion.number,
url: discussion.url,
title: discussion.title,
},
};
}

if (hasComment) {
core.warning(` Discussion #${discussion.number} already has an expiration comment, skipping to avoid duplicate`);

core.info(` Attempting to close discussion #${discussion.number} without adding another comment`);
await closeDiscussionAsOutdated(github, discussion.id);
core.info(` ✓ Discussion closed successfully`);

return {
status: "skipped",
record: {
number: discussion.number,
url: discussion.url,
title: discussion.title,
},
};
}
processEntity: createExpiredEntityHandler({
workflowName,
workflowId,
runUrl,
entityNoun: "discussion",
entityLabel: "Discussion",
core,
footerSuffix: "\n\n<!-- gh-aw-closed -->",
beforeComment: async discussion => {
core.info(` Checking for existing expiration comment and closed state on discussion #${discussion.number}`);
const { hasComment, isClosed } = await hasExpirationComment(github, discussion.id);

if (isClosed) {
core.warning(` Discussion #${discussion.number} is already closed, skipping`);
return {
status: "skipped",
record: createClosedRecord(discussion),
};
}

const cautionAlert = getExpiredEntityCautionAlert(workflowName, runUrl);
const expirationText = `This discussion was automatically closed because it expired on ${formatDateInProjectTimeZone(discussion.expirationDate) || discussion.expirationDate.toISOString()}.`;
const closingMessage = (cautionAlert ? cautionAlert + "\n\n" : "") + expirationText + generateExpiredEntityFooter(workflowName, runUrl, workflowId) + "\n\n<!-- gh-aw-closed -->";
if (hasComment) {
core.warning(` Discussion #${discussion.number} already has an expiration comment, skipping to avoid duplicate`);

core.info(` Adding closing comment to discussion #${discussion.number}`);
await addDiscussionComment(github, discussion.id, closingMessage);
core.info(` ✓ Comment added successfully`);
core.info(` Attempting to close discussion #${discussion.number} without adding another comment`);
await closeDiscussionAsOutdated(github, discussion.id);
core.info(` ✓ Discussion closed successfully`);

core.info(` Closing discussion #${discussion.number} as outdated`);
await closeDiscussionAsOutdated(github, discussion.id);
core.info(` ✓ Discussion closed successfully`);
return {
status: "closed",
record: createClosedRecord(discussion),
};
}

return {
status: "closed",
record: {
number: discussion.number,
url: discussion.url,
title: discussion.title,
},
};
},
return undefined;
},
beforeCommentLog: discussion => ` Adding closing comment to discussion #${discussion.number}`,
beforeCloseLog: discussion => ` Closing discussion #${discussion.number} as outdated`,
addComment: (discussion, message) => addDiscussionComment(github, discussion.id, message),
closeEntity: discussion => closeDiscussionAsOutdated(github, discussion.id),
}),
});
}

Expand Down
Loading
Loading