Skip to content
Closed
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
13 changes: 11 additions & 2 deletions actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@
},
{
"name": "update_project",
"description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title.\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.",
"description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title + temporary_id (to create) or draft_issue_id (to update existing).\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.",
"inputSchema": {
"type": "object",
"required": ["project"],
Expand All @@ -626,12 +626,21 @@
},
"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 (along with temporary_id). Not required when updating via draft_issue_id."
},
"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",
"description": "Project item ID or temporary ID of an existing draft issue to update. Use this to reference a specific draft issue instead of creating a new one. Format: either a GitHub project item ID or a temporary ID (aw_XXXXXXXXXXXX). If provided with content_type='draft_issue', draft_title is optional."
},
"temporary_id": {
"type": "string",
"pattern": "^aw_[0-9a-f]{12}$",
"description": "Temporary identifier for a draft issue being created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Must be provided when creating a new draft issue (content_type='draft_issue' without draft_issue_id); this constraint is enforced by the tool at runtime, not by the static schema validator. Use this to reference the draft issue in subsequent update_project calls via draft_issue_id."
},
"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
174 changes: 153 additions & 21 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 @@ -246,6 +246,53 @@ async function resolveProjectV2(projectInfo, projectNumberInt, github) {

throw new Error(`Project #${projectNumberInt} not found or not accessible for ${who}.${total} Accessible Projects v2: ${summary}`);
}

/**
* Find an existing draft issue in the project by project item ID
* Queries the item directly for efficiency instead of paginating through all items
* @param {string} projectId - Project ID
* @param {string} draftItemId - Draft project item ID to find
* @param {Object} github - GitHub client (Octokit instance)
* @returns {Promise<{id: string, title?: string}|null>} Draft item if found, null otherwise
*/
async function findExistingDraftByItemId(projectId, draftItemId, github) {
try {
const result = await github.graphql(
`query($draftItemId: ID!, $projectId: ID!) {
node(id: $draftItemId) {
... on ProjectV2Item {
id
content {
... on DraftIssue {
id
title
}
}
project {
id
}
}
}
}`,
{ draftItemId, projectId }
);

const node = result?.node;
if (!node || !node.content || node.project?.id !== projectId) {
return null;
}

return {
id: node.id,
title: node.content.title,
};
} catch (error) {
// If the item doesn't exist or isn't accessible, return null
core.debug(`Draft item ${draftItemId} not found: ${getErrorMessage(error)}`);
return null;
}
}

/**
* Check if a field name conflicts with unsupported GitHub built-in field types
* @param {string} fieldName - Original field name
Expand Down Expand Up @@ -611,27 +658,107 @@ 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.');
}
// Check for draft_issue_id to reference an existing draft
let itemId = null;
const hasDraftIssueId = output.draft_issue_id !== undefined && output.draft_issue_id !== null;

const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined;
const result = await github.graphql(
`mutation($projectId: ID!, $title: String!, $body: String) {
addProjectV2DraftIssue(input: {
projectId: $projectId,
title: $title,
body: $body
}) {
projectItem {
id
if (hasDraftIssueId) {
const rawDraftIssueId = output.draft_issue_id;
let sanitizedDraftIssueId = typeof rawDraftIssueId === "string" ? rawDraftIssueId.trim() : String(rawDraftIssueId);

// Strip optional leading # prefix (consistent with content_number and project handling)
if (sanitizedDraftIssueId.startsWith("#")) {
sanitizedDraftIssueId = sanitizedDraftIssueId.substring(1);
}

// Check if it's a temporary ID
if (isTemporaryId(sanitizedDraftIssueId)) {
const normalizedTempId = normalizeTemporaryId(sanitizedDraftIssueId);
const resolved = temporaryIdMap.get(normalizedTempId);

// Handle both string (direct item ID) and structured object formats
let resolvedItemId = null;
if (typeof resolved === "string") {
resolvedItemId = resolved;
} else if (resolved && typeof resolved === "object" && resolved.draftItemId) {
resolvedItemId = resolved.draftItemId;
}

if (resolvedItemId) {
// Temporary ID resolved to a project item ID
core.info(`✓ Resolved temporary ID ${sanitizedDraftIssueId} to draft item ID`);

// Verify the item still exists in the project
const existingItem = await findExistingDraftByItemId(projectId, resolvedItemId, github);
if (existingItem) {
itemId = existingItem.id;
core.info(`✓ Found existing draft item: "${existingItem.title || "Untitled"}"`);
} else {
throw new Error(`Draft item with temporary ID ${sanitizedDraftIssueId} no longer exists in project. It may have been deleted or converted to an issue.`);
}
} else {
throw new Error(`Temporary ID '${sanitizedDraftIssueId}' not found in map. Ensure the draft issue was created in a previous step with a temporary_id field.`);
}
}`,
{ projectId, title: draftTitle, body: draftBody }
);
const itemId = result.addProjectV2DraftIssue.projectItem.id;
} else {
// Direct project item ID provided
const existingItem = await findExistingDraftByItemId(projectId, sanitizedDraftIssueId, github);
if (existingItem) {
itemId = existingItem.id;
core.info(`✓ Found existing draft item: "${existingItem.title || "Untitled"}"`);
} else {
throw new Error(`Draft item with ID "${sanitizedDraftIssueId}" not found in project. Verify the draft_issue_id is correct.`);
}
}
}

// If no itemId found yet, create a new draft issue
if (!itemId) {
// Require temporary_id for new draft creation (similar to content_number for issues/PRs)
let tempIdValue = output.temporary_id;

// Strip optional leading # prefix
if (typeof tempIdValue === "string" && tempIdValue.startsWith("#")) {
tempIdValue = tempIdValue.substring(1);
}

if (!tempIdValue || !isTemporaryId(String(tempIdValue))) {
throw new Error(
'When content_type is "draft_issue" and creating a new draft, temporary_id is required. ' +
'Provide a valid temporary ID (format: aw_XXXXXXXXXXXX, e.g., "aw_abc123def456"). ' +
"To update an existing draft, use draft_issue_id instead."
);
}

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.');
}

const draftBody = typeof output.draft_body === "string" ? output.draft_body : undefined;
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}"`);

// Store the temporary_id mapping as a structured object to distinguish from issue mappings
const normalizedTempId = normalizeTemporaryId(String(tempIdValue));
const draftMapping = { draftItemId: itemId };
temporaryIdMap.set(normalizedTempId, draftMapping);
core.setOutput("temporary-id", normalizedTempId);
core.info(`✓ Stored temporary ID mapping: ${normalizedTempId} -> ${itemId}`);
}

const fieldsToUpdate = output.fields ? { ...output.fields } : {};
if (Object.keys(fieldsToUpdate).length > 0) {
Expand Down Expand Up @@ -1042,13 +1169,18 @@ async function main(config = {}, githubClient = null) {

// Provide helpful context based on content_type
if (message.content_type === "draft_issue") {
core.error('For draft_issue content_type, you must include: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_title": "...", "fields": {...}}');
core.error(
'For draft_issue content_type, you must include: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_title": "...", "temporary_id": "aw_abc123def456", "fields": {...}}'
);
core.error('Or to update an existing draft: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_issue_id": "aw_abc123def456", "fields": {...}}');
} else if (message.content_type === "issue" || message.content_type === "pull_request") {
core.error(
`For ${message.content_type} content_type, you must include: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "${message.content_type}", "content_number": 123, "fields": {...}}`
);
} else {
core.error('Example: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_title": "Task Title", "fields": {"Status": "Todo"}}');
core.error(
'Example: {"type": "update_project", "project": "https://github.com/orgs/myorg/projects/42", "content_type": "draft_issue", "draft_title": "Task Title", "temporary_id": "aw_abc123def456", "fields": {"Status": "Todo"}}'
);
}

return {
Expand Down
Loading