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
79 changes: 48 additions & 31 deletions actions/setup/js/update_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,45 +39,57 @@ async function executeIssueUpdate(github, context, issueNumber, updateData) {
const operation = updateData._operation || "append";
let rawBody = updateData._rawBody;
const includeFooter = updateData._includeFooter !== false; // Default to true
const titlePrefix = updateData._titlePrefix || "";

// Remove internal fields
const { _operation, _rawBody, _includeFooter, ...apiData } = updateData;

// If we have a body, process it with the appropriate operation
if (rawBody !== undefined) {
// Load and apply temporary project URL replacements FIRST
// This resolves any temporary project IDs (e.g., #aw_abc123def456) to actual project URLs
const temporaryProjectMap = loadTemporaryProjectMap();
if (temporaryProjectMap.size > 0) {
rawBody = replaceTemporaryProjectReferences(rawBody, temporaryProjectMap);
core.debug(`Applied ${temporaryProjectMap.size} temporary project URL replacement(s)`);
}
const { _operation, _rawBody, _includeFooter, _titlePrefix, ...apiData } = updateData;

// Fetch current issue body for all operations (needed for append/prepend/replace-island/replace)
// Fetch current issue if needed (title prefix validation or body update)
if (titlePrefix || rawBody !== undefined) {
const { data: currentIssue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const currentBody = currentIssue.body || "";

// Get workflow run URL for AI attribution
const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow";
const workflowId = process.env.GH_AW_WORKFLOW_ID || "";
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;

// Use helper to update body (handles all operations including replace)
apiData.body = updateBody({
currentBody,
newContent: rawBody,
operation,
workflowName,
runUrl,
workflowId,
includeFooter, // Pass footer flag to helper
});

core.info(`Will update body (length: ${apiData.body.length})`);
// Validate title prefix if specified
if (titlePrefix) {
const currentTitle = currentIssue.title || "";
if (!currentTitle.startsWith(titlePrefix)) {
throw new Error(`Issue title "${currentTitle}" does not start with required prefix "${titlePrefix}"`);
}
core.info(`✓ Title prefix validation passed: "${titlePrefix}"`);
}

if (rawBody !== undefined) {
// Load and apply temporary project URL replacements FIRST
// This resolves any temporary project IDs (e.g., #aw_abc123def456) to actual project URLs
const temporaryProjectMap = loadTemporaryProjectMap();
if (temporaryProjectMap.size > 0) {
rawBody = replaceTemporaryProjectReferences(rawBody, temporaryProjectMap);
core.debug(`Applied ${temporaryProjectMap.size} temporary project URL replacement(s)`);
}

const currentBody = currentIssue.body || "";

// Get workflow run URL for AI attribution
const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow";
const workflowId = process.env.GH_AW_WORKFLOW_ID || "";
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;

// Use helper to update body (handles all operations including replace)
apiData.body = updateBody({
currentBody,
newContent: rawBody,
operation,
workflowName,
runUrl,
workflowId,
includeFooter, // Pass footer flag to helper
});

core.info(`Will update body (length: ${apiData.body.length})`);
}
}

const { data: issue } = await github.rest.issues.update({
Expand Down Expand Up @@ -111,7 +123,7 @@ function buildIssueUpdateData(item, config) {
const updateData = {};

if (item.title !== undefined) {
// Sanitize title for Unicode security (no prefix handling needed for updates)
// Sanitize title for Unicode security
updateData.title = sanitizeTitle(item.title);
}
// Check if body updates are allowed (defaults to true if not specified)
Expand Down Expand Up @@ -158,6 +170,11 @@ function buildIssueUpdateData(item, config) {
// Pass footer config to executeUpdate (default to true)
updateData._includeFooter = config.footer !== false;

// Store title prefix for validation in executeIssueUpdate
if (config.title_prefix) {
updateData._titlePrefix = config.title_prefix;
}

return { success: true, data: updateData };
}

Expand Down
84 changes: 84 additions & 0 deletions actions/setup/js/update_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -657,3 +657,87 @@ describe("update_issue.cjs - allow_body configuration", () => {
expect(result.error).toContain("received 6");
});
});

describe("update_issue.cjs - title_prefix configuration", () => {
beforeEach(async () => {
vi.resetAllMocks();
vi.resetModules();
});

it("should store _titlePrefix in updateData when title_prefix is configured", async () => {
const { buildIssueUpdateData } = await import("./update_issue.cjs");

const item = { body: "New content" };
const config = { title_prefix: "[bot] " };

const result = buildIssueUpdateData(item, config);

expect(result.success).toBe(true);
expect(result.data._titlePrefix).toBe("[bot] ");
});

it("should not set _titlePrefix when title_prefix is not configured", async () => {
const { buildIssueUpdateData } = await import("./update_issue.cjs");

const item = { body: "New content" };
const config = {};

const result = buildIssueUpdateData(item, config);

expect(result.success).toBe(true);
expect(result.data._titlePrefix).toBeUndefined();
});

it("should validate title prefix and succeed when issue title starts with prefix", async () => {
// Set up mocks for the full handler flow
mockGithub.rest.issues.get.mockResolvedValueOnce({
data: {
number: 100,
title: "[bot] Fix something",
body: "Original body",
html_url: "https://github.com/testowner/testrepo/issues/100",
},
});
mockGithub.rest.issues.update.mockResolvedValueOnce({
data: {
number: 100,
title: "[bot] Fix something",
body: "Updated",
html_url: "https://github.com/testowner/testrepo/issues/100",
},
});

const { main } = await import("./update_issue.cjs");
const handler = await main({ title_prefix: "[bot] ", allow_body: true });

const message = { issue_number: 100, body: "Updated" };
const result = await handler(message, {});

expect(result.success).toBe(true);
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Title prefix validation passed"));
});

it("should reject update when issue title does not start with required prefix", async () => {
// Set up mock to return issue with wrong title prefix
mockGithub.rest.issues.get.mockResolvedValueOnce({
data: {
number: 100,
title: "Some other issue",
body: "Original body",
html_url: "https://github.com/testowner/testrepo/issues/100",
},
});

const { main } = await import("./update_issue.cjs");
const handler = await main({ title_prefix: "[bot] ", allow_body: true });

const message = { issue_number: 100, body: "Updated" };
const result = await handler(message, {});

expect(result.success).toBe(false);
expect(result.error).toContain("[bot] ");
expect(result.error).toContain("Some other issue");
// Should not call update API
expect(mockGithub.rest.issues.update).not.toHaveBeenCalled();
});
});
22 changes: 14 additions & 8 deletions docs/public/editor/autocomplete-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
"desc": "Optional array of workflow specifications to import (similar to @include directives but defined in frontmatter).",
"array": true
},
"inlined-imports": {
"type": "boolean",
"desc": "If true, inline all imports (including those without inputs) at compilation time in the generated lock.yml instead of...",
"enum": [true, false],
"leaf": true
},
"on": {
"type": "string|object",
"desc": "Workflow triggers that define when the agentic workflow should run.",
Expand Down Expand Up @@ -739,12 +745,12 @@
"engine": {
"type": "string|object",
"desc": "AI engine configuration that specifies which AI processor interprets and executes the markdown content of the workflow.",
"enum": ["claude", "codex", "copilot"],
"enum": ["claude", "codex", "copilot", "gemini"],
"children": {
"id": {
"type": "string",
"desc": "AI engine identifier: 'claude' (Claude Code), 'codex' (OpenAI Codex CLI), or 'copilot' (GitHub Copilot CLI)",
"enum": ["claude", "codex", "copilot"],
"desc": "AI engine identifier: 'claude' (Claude Code), 'codex' (OpenAI Codex CLI), 'copilot' (GitHub Copilot CLI), or 'gemini'...",
"enum": ["claude", "codex", "copilot", "gemini"],
"leaf": true
},
"version": {
Expand Down Expand Up @@ -793,11 +799,6 @@
"type": "object",
"desc": "Custom environment variables to pass to the AI engine, including secret overrides (e.g., OPENAI_API_KEY: ${{ secrets...."
},
"steps": {
"type": "array",
"desc": "Custom GitHub Actions steps for 'custom' engine.",
"array": true
},
"error_patterns": {
"type": "array",
"desc": "Custom error patterns for validating agent logs",
Expand Down Expand Up @@ -1167,6 +1168,11 @@
"desc": "If true, only checks if cache entry exists and skips download",
"enum": [true, false],
"leaf": true
},
"name": {
"type": "string",
"desc": "Optional custom name for the cache step (overrides auto-generated name)",
"leaf": true
}
},
"array": true
Expand Down
Loading
Loading