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
12 changes: 11 additions & 1 deletion actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -626,12 +626,22 @@
},
"draft_title": {
"type": "string",
"description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'."
"description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue' and creating a new draft (when draft_issue_id is not provided)."
},
"draft_body": {
"type": "string",
"description": "Optional body for a Projects v2 draft issue (markdown). Only used when content_type is 'draft_issue'."
},
"draft_issue_id": {
"type": "string",
"pattern": "^#?aw_[0-9a-f]{12}$",
"description": "Temporary ID of an existing draft issue to update (e.g., 'aw_abc123def456' or '#aw_abc123def456'). Use this to reference a draft created earlier with a matching temporary_id. When provided, draft_title is not required for updates."
},
"temporary_id": {
"type": "string",
"pattern": "^#?aw_[0-9a-f]{12}$",
"description": "Unique temporary identifier for this draft issue (e.g., 'aw_abc123def456' or '#aw_abc123def456'). Provide this when creating a new draft to enable future updates via draft_issue_id. Format: optional leading '#', then 'aw_' followed by 12 hex characters."
},
"fields": {
"type": "object",
"description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project."
Expand Down
186 changes: 126 additions & 60 deletions actions/setup/js/update_project.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

const { loadAgentOutput } = require("./load_agent_output.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { loadTemporaryIdMap, resolveIssueNumber } = require("./temporary_id.cjs");
const { loadTemporaryIdMap, resolveIssueNumber, isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs");

/**
* Log detailed GraphQL error information
Expand Down Expand Up @@ -308,6 +308,55 @@ function checkFieldTypeMismatch(fieldName, field, expectedDataType) {
);
return false; // Continue with existing field type
}

/**
* Find an existing draft issue by title in a project
* @param {Object} github - GitHub client (Octokit instance)
* @param {string} projectId - Project ID
* @param {string} targetTitle - Title to search for
* @returns {Promise<{id: string} | null>} Draft item or null if not found
*/
async function findExistingDraftByTitle(github, projectId, targetTitle) {
let hasNextPage = true;
let endCursor = null;

while (hasNextPage) {
const result = await github.graphql(
`query($projectId: ID!, $after: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $after) {
nodes {
id
content {
__typename
... on DraftIssue {
id
title
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}`,
{ projectId, after: endCursor }
);

const found = result.node.items.nodes.find(item => item.content?.__typename === "DraftIssue" && item.content.title === targetTitle);
if (found) return found;

hasNextPage = result.node.items.pageInfo.hasNextPage;
endCursor = result.node.items.pageInfo.endCursor;
}

return null;
}

/**
* Update a GitHub Project v2
* @param {any} output - Safe output configuration
Expand Down Expand Up @@ -611,76 +660,90 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient =
core.warning('content_number/issue/pull_request is ignored when content_type is "draft_issue".');
}

const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : "";
if (!draftTitle) {
throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required and must be a non-empty string.');
// Extract and normalize temporary_id and draft_issue_id using shared helpers
const rawTemporaryId = typeof output.temporary_id === "string" ? output.temporary_id.trim() : "";
const temporaryId = rawTemporaryId.startsWith("#") ? rawTemporaryId.slice(1) : rawTemporaryId;

const rawDraftIssueId = typeof output.draft_issue_id === "string" ? output.draft_issue_id.trim() : "";
const draftIssueId = rawDraftIssueId.startsWith("#") ? rawDraftIssueId.slice(1) : rawDraftIssueId;

// Validate temporary_id format if provided
if (temporaryId && !isTemporaryId(temporaryId)) {
throw new Error(`Invalid temporary_id format: "${temporaryId}". Expected format: aw_ followed by 12 hex characters (e.g., "aw_abc123def456").`);
}

// Validate draft_issue_id format if provided
if (draftIssueId && !isTemporaryId(draftIssueId)) {
throw new Error(`Invalid draft_issue_id format: "${draftIssueId}". Expected format: aw_ followed by 12 hex characters (e.g., "aw_abc123def456").`);
}

const draftTitle = typeof output.draft_title === "string" ? output.draft_title.trim() : "";
const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined;

// Check for existing draft issue with the same title
const existingDraftItem = await (async function findExistingDraftByTitle(projectId, targetTitle) {
let hasNextPage = true;
let endCursor = null;
let itemId;
let resolvedTemporaryId = temporaryId;

// Mode 1: Update existing draft via draft_issue_id
if (draftIssueId) {
// Try to resolve draft_issue_id from temporaryIdMap using normalized ID
const normalized = normalizeTemporaryId(draftIssueId);
const resolved = temporaryIdMap.get(normalized);
if (resolved && resolved.draftItemId) {
itemId = resolved.draftItemId;
core.info(`✓ Resolved draft_issue_id "${draftIssueId}" to item ${itemId}`);
} else {
// Fall back to title-based lookup if title is provided
if (draftTitle) {
const existingDraftItem = await findExistingDraftByTitle(github, projectId, draftTitle);

if (existingDraftItem) {
itemId = existingDraftItem.id;
core.info(`✓ Found draft issue "${draftTitle}" by title fallback`);
} else {
throw new Error(`draft_issue_id "${draftIssueId}" not found in temporary ID map and no draft with title "${draftTitle}" found`);
}
} else {
throw new Error(`draft_issue_id "${draftIssueId}" not found in temporary ID map and no draft_title provided for fallback lookup`);
}
}
}
// Mode 2: Create new draft or find by title
else {
if (!draftTitle) {
throw new Error('Invalid draft_title. When content_type is "draft_issue" and draft_issue_id is not provided, draft_title is required and must be a non-empty string.');
}

// Check for existing draft issue with the same title
const existingDraftItem = await findExistingDraftByTitle(github, projectId, draftTitle);

while (hasNextPage) {
if (existingDraftItem) {
itemId = existingDraftItem.id;
core.info(`✓ Found existing draft issue "${draftTitle}" - updating fields instead of creating duplicate`);
} else {
const result = await github.graphql(
`query($projectId: ID!, $after: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $after) {
nodes {
id
content {
__typename
... on DraftIssue {
id
title
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
`mutation($projectId: ID!, $title: String!, $body: String) {
addProjectV2DraftIssue(input: {
projectId: $projectId,
title: $title,
body: $body
}) {
projectItem {
id
}
}
}`,
{ projectId, after: endCursor }
{ projectId, title: draftTitle, body: draftBody }
);

const found = result.node.items.nodes.find(item => item.content?.__typename === "DraftIssue" && item.content.title === targetTitle);
if (found) return found;

hasNextPage = result.node.items.pageInfo.hasNextPage;
endCursor = result.node.items.pageInfo.endCursor;
itemId = result.addProjectV2DraftIssue.projectItem.id;
core.info(`✓ Created new draft issue "${draftTitle}"`);

// Store temporary_id mapping if provided
if (temporaryId) {
const normalized = normalizeTemporaryId(temporaryId);
temporaryIdMap.set(normalized, { draftItemId: itemId });
core.info(`✓ Stored temporary_id mapping: ${temporaryId} -> ${itemId}`);
}
}

return null;
})(projectId, draftTitle);

let itemId;
if (existingDraftItem) {
itemId = existingDraftItem.id;
core.info(`✓ Found existing draft issue "${draftTitle}" - updating fields instead of creating duplicate`);
} else {
const result = await github.graphql(
`mutation($projectId: ID!, $title: String!, $body: String) {
addProjectV2DraftIssue(input: {
projectId: $projectId,
title: $title,
body: $body
}) {
projectItem {
id
}
}
}`,
{ projectId, title: draftTitle, body: draftBody }
);
itemId = result.addProjectV2DraftIssue.projectItem.id;
core.info(`✓ Created new draft issue "${draftTitle}"`);
}

const fieldsToUpdate = output.fields ? { ...output.fields } : {};
Expand Down Expand Up @@ -811,6 +874,9 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient =
}

core.setOutput("item-id", itemId);
if (resolvedTemporaryId) {
core.setOutput("temporary-id", resolvedTemporaryId);
}
return;
}
let contentNumber = null;
Expand Down
Loading
Loading