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
3 changes: 2 additions & 1 deletion actions/setup/js/add_reaction_and_edit_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

const { getRunStartedMessage } = require("./messages_run_status.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { generateWorkflowIdMarker } = require("./generate_footer.cjs");

async function main() {
// Read inputs from environment variables
Expand Down Expand Up @@ -341,7 +342,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) {

// Add workflow-id marker if available
if (workflowId) {
commentBody += `\n\n<!-- gh-aw-workflow-id: ${workflowId} -->`;
commentBody += `\n\n${generateWorkflowIdMarker(workflowId)}`;
}

// Add tracker-id marker if available (for backwards compatibility)
Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/add_workflow_run_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

const { getRunStartedMessage } = require("./messages_run_status.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { generateWorkflowIdMarker } = require("./generate_footer.cjs");

/**
* Add a comment with a workflow run link to the triggering item.
Expand Down Expand Up @@ -153,7 +154,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) {

// Add workflow-id marker if available
if (workflowId) {
commentBody += `\n\n<!-- gh-aw-workflow-id: ${workflowId} -->`;
commentBody += `\n\n${generateWorkflowIdMarker(workflowId)}`;
}

// Add tracker-id marker if available (for backwards compatibility)
Expand Down
81 changes: 20 additions & 61 deletions actions/setup/js/close_older_issues.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// <reference types="@actions/github-script" />

const { getErrorMessage } = require("./error_helpers.cjs");
const { getWorkflowIdMarkerContent } = require("./generate_footer.cjs");

/**
* Maximum number of older issues to close
Expand All @@ -23,44 +24,32 @@ function delay(ms) {
}

/**
* Search for open issues with a matching title prefix and/or labels
* Search for open issues with a matching workflow-id marker
* @param {any} github - GitHub REST API instance
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching)
* @param {string[]} labels - Labels to match (empty array to skip label matching)
* @param {string} workflowId - Workflow ID to match in the marker
* @param {number} excludeNumber - Issue number to exclude (the newly created one)
* @returns {Promise<Array<{number: number, title: string, html_url: string, labels: Array<{name: string}>}>>} Matching issues
*/
async function searchOlderIssues(github, owner, repo, titlePrefix, labels, excludeNumber) {
async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber) {
core.info(`Starting search for older issues in ${owner}/${repo}`);
core.info(` Title prefix: ${titlePrefix || "(none)"}`);
core.info(` Labels: ${labels && labels.length > 0 ? labels.join(", ") : "(none)"}`);
core.info(` Workflow ID: ${workflowId || "(none)"}`);
core.info(` Exclude issue number: ${excludeNumber}`);

// Build REST API search query
// Search for open issues, optionally with title prefix or labels
let searchQuery = `repo:${owner}/${repo} is:issue is:open`;

if (titlePrefix) {
// Escape quotes in title prefix to prevent query injection
const escapedPrefix = titlePrefix.replace(/"/g, '\\"');
searchQuery += ` in:title "${escapedPrefix}"`;
core.info(` Added title filter to query: in:title "${escapedPrefix}"`);
if (!workflowId) {
core.info("No workflow ID provided - cannot search for older issues");
return [];
}

// Add label filters to the search query
// Note: GitHub search uses AND logic for multiple labels, so issues must have ALL labels.
// We add each label as a separate filter and also validate client-side for extra safety.
if (labels && labels.length > 0) {
for (const label of labels) {
// Escape quotes in label names to prevent query injection
const escapedLabel = label.replace(/"/g, '\\"');
searchQuery += ` label:"${escapedLabel}"`;
core.info(` Added label filter to query: label:"${escapedLabel}"`);
}
}
// Build REST API search query
// Search for open issues with the workflow-id marker in the body
const workflowIdMarker = getWorkflowIdMarkerContent(workflowId);
// Escape quotes in workflow ID to prevent query injection
const escapedMarker = workflowIdMarker.replace(/"/g, '\\"');
const searchQuery = `repo:${owner}/${repo} is:issue is:open "${escapedMarker}" in:body`;

core.info(` Added workflow-id marker filter to query: "${escapedMarker}" in:body`);
core.info(`Executing GitHub search with query: ${searchQuery}`);

const result = await github.rest.search.issuesAndPullRequests({
Expand All @@ -78,14 +67,10 @@ async function searchOlderIssues(github, owner, repo, titlePrefix, labels, exclu
// Filter results:
// 1. Must not be the excluded issue (newly created one)
// 2. Must not be a pull request
// 3. If titlePrefix is specified, must have title starting with the prefix
// 4. If labels are specified, must have ALL specified labels (AND logic, not OR)
core.info("Filtering search results...");
let filteredCount = 0;
let pullRequestCount = 0;
let excludedCount = 0;
let titleMismatchCount = 0;
let labelMismatchCount = 0;

const filtered = result.data.items
.filter(item => {
Expand All @@ -102,25 +87,6 @@ async function searchOlderIssues(github, owner, repo, titlePrefix, labels, exclu
return false;
}

// Check title prefix if specified
if (titlePrefix && item.title && !item.title.startsWith(titlePrefix)) {
titleMismatchCount++;
core.info(` Excluding issue #${item.number}: title "${item.title}" does not start with "${titlePrefix}"`);
return false;
}

// Check labels if specified - requires ALL labels to match (AND logic)
// This is intentional: we only want to close issues that have ALL the specified labels
if (labels && labels.length > 0) {
const issueLabels = item.labels?.map(l => l.name) || [];
const hasAllLabels = labels.every(label => issueLabels.includes(label));
if (!hasAllLabels) {
labelMismatchCount++;
core.info(` Excluding issue #${item.number}: labels [${issueLabels.join(", ")}] do not include all required labels [${labels.join(", ")}]`);
return false;
}
}

filteredCount++;
core.info(` ✓ Issue #${item.number} matches criteria: ${item.title}`);
return true;
Expand All @@ -136,8 +102,6 @@ async function searchOlderIssues(github, owner, repo, titlePrefix, labels, exclu
core.info(` - Matched issues: ${filteredCount}`);
core.info(` - Excluded pull requests: ${pullRequestCount}`);
core.info(` - Excluded new issue: ${excludedCount}`);
core.info(` - Excluded title mismatches: ${titleMismatchCount}`);
core.info(` - Excluded label mismatches: ${labelMismatchCount}`);

return filtered;
}
Expand Down Expand Up @@ -219,33 +183,28 @@ function getCloseOlderIssueMessage({ newIssueUrl, newIssueNumber, workflowName,
}

/**
* Close older issues that match the title prefix and/or labels
* Close older issues that match the workflow-id marker
* @param {any} github - GitHub REST API instance
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} titlePrefix - Title prefix to match (empty string to skip)
* @param {string[]} labels - Labels to match (empty array to skip)
* @param {string} workflowId - Workflow ID to match in the marker
* @param {{number: number, html_url: string}} newIssue - The newly created issue
* @param {string} workflowName - Name of the workflow
* @param {string} runUrl - URL of the workflow run
* @returns {Promise<Array<{number: number, html_url: string}>>} List of closed issues
*/
async function closeOlderIssues(github, owner, repo, titlePrefix, labels, newIssue, workflowName, runUrl) {
async function closeOlderIssues(github, owner, repo, workflowId, newIssue, workflowName, runUrl) {
core.info("=".repeat(70));
core.info("Starting closeOlderIssues operation");
core.info("=".repeat(70));

// Build search criteria description for logging
const searchCriteria = [];
if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`);
if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`);
core.info(`Search criteria: ${searchCriteria.length > 0 ? searchCriteria.join(" and ") : "(none specified)"}`);
core.info(`Search criteria: workflow-id marker: "${getWorkflowIdMarkerContent(workflowId)}"`);
core.info(`New issue reference: #${newIssue.number} (${newIssue.html_url})`);
core.info(`Workflow: ${workflowName}`);
core.info(`Run URL: ${runUrl}`);
core.info("");

const olderIssues = await searchOlderIssues(github, owner, repo, titlePrefix, labels, newIssue.number);
const olderIssues = await searchOlderIssues(github, owner, repo, workflowId, newIssue.number);

if (olderIssues.length === 0) {
core.info("✓ No older issues found to close - operation complete");
Expand Down
47 changes: 14 additions & 33 deletions actions/setup/js/close_older_issues.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe("close_older_issues", () => {
});

describe("searchOlderIssues", () => {
it("should search for issues with title prefix", async () => {
it("should search for issues with workflow-id marker", async () => {
mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
data: {
items: [
Expand All @@ -49,13 +49,13 @@ describe("close_older_issues", () => {
},
});

const results = await searchOlderIssues(mockGithub, "owner", "repo", "Weekly Report", [], 125);
const results = await searchOlderIssues(mockGithub, "owner", "repo", "test-workflow", 125);

expect(results).toHaveLength(2);
expect(results[0].number).toBe(123);
expect(results[1].number).toBe(124);
expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({
q: 'repo:owner/repo is:issue is:open in:title "Weekly Report"',
q: 'repo:owner/repo is:issue is:open "gh-aw-workflow-id: test-workflow" in:body',
per_page: 50,
});
});
Expand All @@ -80,36 +80,17 @@ describe("close_older_issues", () => {
},
});

const results = await searchOlderIssues(mockGithub, "owner", "repo", "Weekly Report", [], 124);
const results = await searchOlderIssues(mockGithub, "owner", "repo", "test-workflow", 124);

expect(results).toHaveLength(1);
expect(results[0].number).toBe(123);
});

it("should filter issues by labels (AND logic)", async () => {
mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
data: {
items: [
{
number: 123,
title: "Report 1",
html_url: "https://github.com/owner/repo/issues/123",
labels: [{ name: "report" }, { name: "automation" }],
},
{
number: 124,
title: "Report 2",
html_url: "https://github.com/owner/repo/issues/124",
labels: [{ name: "report" }], // Missing "automation" label
},
],
},
});

const results = await searchOlderIssues(mockGithub, "owner", "repo", "", ["report", "automation"], 125);
it("should return empty array if no workflow-id provided", async () => {
const results = await searchOlderIssues(mockGithub, "owner", "repo", "", 125);

expect(results).toHaveLength(1);
expect(results[0].number).toBe(123); // Only issue with both labels
expect(results).toHaveLength(0);
expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled();
});

it("should exclude pull requests", async () => {
Expand All @@ -133,7 +114,7 @@ describe("close_older_issues", () => {
},
});

const results = await searchOlderIssues(mockGithub, "owner", "repo", "", [], 125);
const results = await searchOlderIssues(mockGithub, "owner", "repo", "test-workflow", 125);

expect(results).toHaveLength(1);
expect(results[0].number).toBe(123);
Expand All @@ -146,7 +127,7 @@ describe("close_older_issues", () => {
},
});

const results = await searchOlderIssues(mockGithub, "owner", "repo", "Prefix", [], 125);
const results = await searchOlderIssues(mockGithub, "owner", "repo", "test-workflow", 125);

expect(results).toHaveLength(0);
});
Expand Down Expand Up @@ -241,7 +222,7 @@ describe("close_older_issues", () => {
});

const newIssue = { number: 125, html_url: "https://github.com/owner/repo/issues/125" };
const results = await closeOlderIssues(mockGithub, "owner", "repo", "Prefix", [], newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123");
const results = await closeOlderIssues(mockGithub, "owner", "repo", "test-workflow", newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123");

expect(results).toHaveLength(1);
expect(results[0].number).toBe(123);
Expand Down Expand Up @@ -273,7 +254,7 @@ describe("close_older_issues", () => {
});

const newIssue = { number: 20, html_url: "https://github.com/owner/repo/issues/20" };
const results = await closeOlderIssues(mockGithub, "owner", "repo", "", [], newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123");
const results = await closeOlderIssues(mockGithub, "owner", "repo", "test-workflow", newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123");

expect(results).toHaveLength(MAX_CLOSE_COUNT);
expect(global.core.warning).toHaveBeenCalledWith(`⚠️ Found 15 older issues, but only closing the first ${MAX_CLOSE_COUNT}`);
Expand Down Expand Up @@ -312,7 +293,7 @@ describe("close_older_issues", () => {
});

const newIssue = { number: 125, html_url: "https://github.com/owner/repo/issues/125" };
const results = await closeOlderIssues(mockGithub, "owner", "repo", "", [], newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123");
const results = await closeOlderIssues(mockGithub, "owner", "repo", "test-workflow", newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123");

expect(results).toHaveLength(1);
expect(results[0].number).toBe(124);
Expand All @@ -325,7 +306,7 @@ describe("close_older_issues", () => {
});

const newIssue = { number: 125, html_url: "https://github.com/owner/repo/issues/125" };
const results = await closeOlderIssues(mockGithub, "owner", "repo", "Prefix", [], newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123");
const results = await closeOlderIssues(mockGithub, "owner", "repo", "test-workflow", newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123");

expect(results).toHaveLength(0);
expect(global.core.info).toHaveBeenCalledWith("✓ No older issues found to close - operation complete");
Expand Down
11 changes: 10 additions & 1 deletion actions/setup/js/create_discussion.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } =
const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { createExpirationLine, generateFooterWithExpiration } = require("./ephemerals.cjs");
const { generateWorkflowIdMarker } = require("./generate_footer.cjs");

/**
* Fetch repository ID and discussion categories for a repository
Expand Down Expand Up @@ -255,6 +256,7 @@ async function main(config = {}) {
}

const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow";
const workflowId = process.env.GH_AW_WORKFLOW_ID || "";
const runId = context.runId;
const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
Expand All @@ -266,7 +268,14 @@ async function main(config = {}) {
entityType: "Discussion",
});

bodyLines.push(``, ``, footer, "");
bodyLines.push(``, ``, footer);

// Add standalone workflow-id marker for searchability (consistent with comments)
if (workflowId) {
bodyLines.push(``, generateWorkflowIdMarker(workflowId));
}

bodyLines.push("");
const body = bodyLines.join("\n").trim();

core.info(`Creating discussion in ${qualifiedItemRepo} with title: ${title}`);
Expand Down
Loading
Loading