From 074ffd305d99853575279fe494a7d6bea859cf24 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 05:31:10 +0000 Subject: [PATCH 1/4] jsweep: clean update_project.cjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract duplicated ~120-line field update loop into shared applyFieldUpdates(github, projectId, itemId, fields) helper, eliminating ~240 lines of duplication across draft_issue and content_number branches - Extract IIFE for item lookup into named findExistingItemByContentId(github, projectId, contentId) function comma-chained consts, IIFE) with clean readable code - Expand GraphQL mutation strings from single-line escaped \n sequences into readable multi-line template literals - Replace index-based for loop with for...of .entries() on configuredViews - Export normalizeUpdateProjectOutput, summarizeProjectsV2, and summarizeEmptyProjectsV2List utility functions for direct testing - Add 22 new tests covering normalizeUpdateProjectOutput (camelCase alias normalization), summarizeProjectsV2 (formatting, filtering, limit), and summarizeEmptyProjectsV2List (diagnostics, SSO hint, empty cases) - Tests: 76 → 98 (all passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 558 ++++++++++++----------- actions/setup/js/update_project.test.cjs | 149 ++++++ 2 files changed, 430 insertions(+), 277 deletions(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 0c08781763c..f09f2b48f86 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -364,6 +364,241 @@ async function findExistingDraftByTitle(github, projectId, targetTitle) { return null; } +/** + * Find an existing project item by content ID (issue or PR) + * @param {Object} github - GitHub client (Octokit instance) + * @param {string} projectId - Project node ID + * @param {string} contentId - Content node ID (issue or PR) + * @returns {Promise<{id: string} | null>} Existing project item or null if not found + */ +async function findExistingItemByContentId(github, projectId, contentId) { + 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 { + ... on Issue { + id + } + ... on PullRequest { + id + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + }`, + { projectId, after: endCursor } + ); + + const found = result.node.items.nodes.find(item => item.content?.id === contentId); + if (found) return found; + + hasNextPage = result.node.items.pageInfo.hasNextPage; + endCursor = result.node.items.pageInfo.endCursor; + } + + return null; +} + +/** + * Apply field value updates to a project item, creating fields as needed. + * @param {Object} github - GitHub client (Octokit instance) + * @param {string} projectId - Project node ID + * @param {string} itemId - Project item node ID + * @param {Record} fields - Field name/value pairs to update + * @returns {Promise} + */ +async function applyFieldUpdates(github, projectId, itemId, fields) { + const projectFields = await fetchAllProjectFields(github, projectId); + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + + for (const [fieldName, fieldValue] of Object.entries(fields)) { + const normalizedFieldName = fieldName + .split(/[\s_-]+/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + let field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); + + if (isUnsupportedBuiltInFieldType(fieldName, normalizedFieldName)) { + continue; + } + + const isDateField = fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date"); + const isTextField = fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|")); + let expectedDataType; + if (isDateField && typeof fieldValue === "string" && datePattern.test(fieldValue)) { + expectedDataType = "DATE"; + } else if (isTextField) { + expectedDataType = "TEXT"; + } else { + expectedDataType = "SINGLE_SELECT"; + } + + if (checkFieldTypeMismatch(fieldName, field, expectedDataType)) { + continue; + } + + if (!field) { + if (isDateField) { + if (typeof fieldValue === "string" && datePattern.test(fieldValue)) { + try { + field = ( + await github.graphql( + `mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) { + createProjectV2Field(input: { + projectId: $projectId, + name: $name, + dataType: $dataType + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + dataType + } + } + } + }`, + { projectId, name: normalizedFieldName, dataType: "DATE" } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create date field "${fieldName}": ${getErrorMessage(createError)}`); + continue; + } + } else { + core.warning(`Field "${fieldName}" looks like a date field but value "${fieldValue}" is not in YYYY-MM-DD format. Skipping field creation.`); + continue; + } + } else if (isTextField) { + try { + field = ( + await github.graphql( + `mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) { + createProjectV2Field(input: { + projectId: $projectId, + name: $name, + dataType: $dataType + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + }`, + { projectId, name: normalizedFieldName, dataType: "TEXT" } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); + continue; + } + } else { + try { + field = ( + await github.graphql( + `mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) { + createProjectV2Field(input: { + projectId: $projectId, + name: $name, + dataType: $dataType, + singleSelectOptions: $options + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + ... on ProjectV2Field { + id + name + } + } + } + }`, + { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); + continue; + } + } + } + + let valueToSet; + if (field.dataType === "DATE") { + valueToSet = { date: String(fieldValue) }; + } else if (field.dataType === "NUMBER") { + const numValue = typeof fieldValue === "number" ? fieldValue : parseFloat(String(fieldValue)); + if (isNaN(numValue)) { + core.warning(`Invalid number value "${fieldValue}" for field "${fieldName}"`); + continue; + } + valueToSet = { number: numValue }; + } else if (field.dataType === "ITERATION") { + if (!field.configuration?.iterations) { + core.warning(`Iteration field "${fieldName}" has no configured iterations`); + continue; + } + const iteration = field.configuration.iterations.find(iter => iter.title.toLowerCase() === String(fieldValue).toLowerCase()); + if (!iteration) { + const availableIterations = field.configuration.iterations.map(i => i.title).join(", "); + core.warning(`Iteration "${fieldValue}" not found in field "${fieldName}". Available iterations: ${availableIterations}`); + continue; + } + valueToSet = { iterationId: iteration.id }; + } else if (field.options) { + const option = field.options.find(o => o.name === fieldValue); + if (!option) { + const availableOptions = field.options.map(o => o.name).join(", "); + core.warning(`Option "${fieldValue}" not found in field "${fieldName}". Available options: ${availableOptions}. To add this option, please update the field manually in the GitHub Projects UI.`); + continue; + } + valueToSet = { singleSelectOptionId: option.id }; + } else { + valueToSet = { text: String(fieldValue) }; + } + + await github.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: $value + }) { + projectV2Item { + id + } + } + }`, + { projectId, itemId, fieldId: field.id, value: valueToSet } + ); + } +} + /** * Fetch all fields for a GitHub Project v2, paginating through all results. * @param {Object} github - GitHub client (Octokit instance) @@ -843,126 +1078,8 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } } - const fieldsToUpdate = output.fields ? { ...output.fields } : {}; - if (Object.keys(fieldsToUpdate).length > 0) { - const projectFields = await fetchAllProjectFields(github, projectId); - for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); - let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); - - // Check if field name conflicts with unsupported built-in types - if (isUnsupportedBuiltInFieldType(fieldName, normalizedFieldName)) { - continue; - } - - // Detect expected field type based on field name and value heuristics - const datePattern = /^\d{4}-\d{2}-\d{2}$/; - const isDateField = fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date"); - const isTextField = "classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|")); - let expectedDataType; - if (isDateField && typeof fieldValue === "string" && datePattern.test(fieldValue)) { - expectedDataType = "DATE"; - } else if (isTextField) { - expectedDataType = "TEXT"; - } else { - expectedDataType = "SINGLE_SELECT"; - } - - // Check for type mismatch if field already exists - if (checkFieldTypeMismatch(fieldName, field, expectedDataType)) { - continue; // Skip fields with unsupported built-in types - } - - if (!field) - if (fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date")) { - // Check if field name suggests it's a date field (e.g., start_date, end_date, due_date) - // Date field values must match ISO 8601 format (YYYY-MM-DD) - if (typeof fieldValue === "string" && datePattern.test(fieldValue)) { - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "DATE" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create date field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - } else { - core.warning(`Field "${fieldName}" looks like a date field but value "${fieldValue}" is not in YYYY-MM-DD format. Skipping field creation.`); - continue; - } - } else if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - else - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - if (field.dataType === "DATE") valueToSet = { date: String(fieldValue) }; - else if (field.dataType === "NUMBER") { - // NUMBER fields use ProjectV2FieldValue input type with number property - // The number value must be a valid float or integer - // Convert string values to numbers if needed - const numValue = typeof fieldValue === "number" ? fieldValue : parseFloat(String(fieldValue)); - if (isNaN(numValue)) { - core.warning(`Invalid number value "${fieldValue}" for field "${fieldName}"`); - continue; - } - valueToSet = { number: numValue }; - } else if (field.dataType === "ITERATION") { - // ITERATION fields use ProjectV2FieldValue input type with iterationId property - // The value should match an iteration title or ID - if (!field.configuration || !field.configuration.iterations) { - core.warning(`Iteration field "${fieldName}" has no configured iterations`); - continue; - } - // Try to find iteration by title (case-insensitive match) - const iteration = field.configuration.iterations.find(iter => iter.title.toLowerCase() === String(fieldValue).toLowerCase()); - if (!iteration) { - const availableIterations = field.configuration.iterations.map(i => i.title).join(", "); - core.warning(`Iteration "${fieldValue}" not found in field "${fieldName}". Available iterations: ${availableIterations}`); - continue; - } - valueToSet = { iterationId: iteration.id }; - } else if (field.options) { - let option = field.options.find(o => o.name === fieldValue); - if (!option) { - // GitHub's GraphQL API does not support adding new options to existing single-select fields - // The updateProjectV2Field mutation does not exist - users must add options manually via UI - const availableOptions = field.options.map(o => o.name).join(", "); - core.warning(`Option "${fieldValue}" not found in field "${fieldName}". Available options: ${availableOptions}. To add this option, please update the field manually in the GitHub Projects UI.`); - continue; - } - valueToSet = { singleSelectOptionId: option.id }; - } else valueToSet = { text: String(fieldValue) }; - await github.graphql( - "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", - { projectId, itemId, fieldId: field.id, value: valueToSet } - ); - } + if (output.fields && Object.keys(output.fields).length > 0) { + await applyFieldUpdates(github, projectId, itemId, output.fields); } core.setOutput("item-id", itemId); @@ -976,13 +1093,13 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = draftItemId: itemId, }; } + let contentNumber = null; if (hasContentNumber || hasIssue || hasPullRequest) { const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request; - const sanitizedContentNumber = null == rawContentNumber ? "" : "number" == typeof rawContentNumber ? rawContentNumber.toString() : String(rawContentNumber).trim(); + const sanitizedContentNumber = rawContentNumber == null ? "" : typeof rawContentNumber === "number" ? rawContentNumber.toString() : String(rawContentNumber).trim(); if (sanitizedContentNumber) { - // Try to resolve as temporary ID first const resolved = resolveIssueNumber(sanitizedContentNumber, temporaryIdMap); if (resolved.wasTemporaryId) { @@ -992,7 +1109,6 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = core.info(`✓ Resolved temporary ID ${sanitizedContentNumber} to issue #${resolved.resolved.number}`); contentNumber = resolved.resolved.number; } else { - // Not a temporary ID - validate as numeric if (!/^\d+$/.test(sanitizedContentNumber)) { throw new Error(`${ERR_VALIDATION}: Invalid content number "${rawContentNumber}". Provide a positive integer or a valid temporary ID (format: aw_ followed by 3-12 alphanumeric characters).`); } @@ -1002,165 +1118,54 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = core.warning("Content number field provided but empty; skipping project item update."); } } - if (null !== contentNumber) { - const contentType = "pull_request" === output.content_type ? "PullRequest" : "issue" === output.content_type || output.issue ? "Issue" : "PullRequest", - contentQuery = - "Issue" === contentType - ? "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n issue(number: $number) {\n id\n }\n }\n }" - : "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n id\n }\n }\n }", - contentResult = await github.graphql(contentQuery, { owner: contentOwner, repo: targetRepo, number: contentNumber }), - contentData = "Issue" === contentType ? contentResult.repository.issue : contentResult.repository.pullRequest, - contentId = contentData.id, - existingItem = await (async function (projectId, contentId) { - let hasNextPage = !0, - endCursor = null; - for (; hasNextPage; ) { - const result = await github.graphql( - "query($projectId: ID!, $after: String) {\n node(id: $projectId) {\n ... on ProjectV2 {\n items(first: 100, after: $after) {\n nodes {\n id\n content {\n ... on Issue {\n id\n }\n ... on PullRequest {\n id\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n }", - { projectId, after: endCursor } - ), - found = result.node.items.nodes.find(item => item.content && item.content.id === contentId); - if (found) return found; - ((hasNextPage = result.node.items.pageInfo.hasNextPage), (endCursor = result.node.items.pageInfo.endCursor)); - } - return null; - })(projectId, contentId); + + if (contentNumber !== null) { + const contentType = output.content_type === "pull_request" ? "PullRequest" : output.content_type === "issue" || output.issue ? "Issue" : "PullRequest"; + const contentQuery = + contentType === "Issue" + ? `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + id + } + } + }` + : `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + } + } + }`; + const contentResult = await github.graphql(contentQuery, { owner: contentOwner, repo: targetRepo, number: contentNumber }); + const contentData = contentType === "Issue" ? contentResult.repository.issue : contentResult.repository.pullRequest; + const contentId = contentData.id; + const existingItem = await findExistingItemByContentId(github, projectId, contentId); + let itemId; - if (existingItem) ((itemId = existingItem.id), core.info("✓ Item already on board")); - else { + if (existingItem) { + itemId = existingItem.id; + core.info("✓ Item already on board"); + } else { itemId = ( await github.graphql( - "mutation($projectId: ID!, $contentId: ID!) {\n addProjectV2ItemById(input: {\n projectId: $projectId,\n contentId: $contentId\n }) {\n item {\n id\n }\n }\n }", + `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { + projectId: $projectId, + contentId: $contentId + }) { + item { + id + } + } + }`, { projectId, contentId } ) ).addProjectV2ItemById.item.id; } - const fieldsToUpdate = output.fields ? { ...output.fields } : {}; - if (Object.keys(fieldsToUpdate).length > 0) { - const projectFields = await fetchAllProjectFields(github, projectId); - for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); - let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); - - // Check if field name conflicts with unsupported built-in types - if (isUnsupportedBuiltInFieldType(fieldName, normalizedFieldName)) { - continue; - } - - // Detect expected field type based on field name and value heuristics - const datePattern = /^\d{4}-\d{2}-\d{2}$/; - const isDateField = fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date"); - const isTextField = "classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|")); - let expectedDataType; - if (isDateField && typeof fieldValue === "string" && datePattern.test(fieldValue)) { - expectedDataType = "DATE"; - } else if (isTextField) { - expectedDataType = "TEXT"; - } else { - expectedDataType = "SINGLE_SELECT"; - } - // Check for type mismatch if field already exists - if (checkFieldTypeMismatch(fieldName, field, expectedDataType)) { - continue; // Skip fields with unsupported built-in types - } - - if (!field) - if (fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date")) { - // Check if field name suggests it's a date field (e.g., start_date, end_date, due_date) - // Date field values must match ISO 8601 format (YYYY-MM-DD) - if (typeof fieldValue === "string" && datePattern.test(fieldValue)) { - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "DATE" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create date field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - } else { - core.warning(`Field "${fieldName}" looks like a date field but value "${fieldValue}" is not in YYYY-MM-DD format. Skipping field creation.`); - continue; - } - } else if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - else - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - // Check dataType first to properly handle DATE fields before checking for options - // This prevents date fields from being misidentified as single-select fields - if (field.dataType === "DATE") { - // Date fields use ProjectV2FieldValue input type with date property - // The date value must be in ISO 8601 format (YYYY-MM-DD) with no time component - // Unlike other field types that may require IDs, date fields accept the date string directly - valueToSet = { date: String(fieldValue) }; - } else if (field.dataType === "NUMBER") { - // NUMBER fields use ProjectV2FieldValue input type with number property - // The number value must be a valid float or integer - // Convert string values to numbers if needed - const numValue = typeof fieldValue === "number" ? fieldValue : parseFloat(String(fieldValue)); - if (isNaN(numValue)) { - core.warning(`Invalid number value "${fieldValue}" for field "${fieldName}"`); - continue; - } - valueToSet = { number: numValue }; - } else if (field.dataType === "ITERATION") { - // ITERATION fields use ProjectV2FieldValue input type with iterationId property - // The value should match an iteration title or ID - if (!field.configuration || !field.configuration.iterations) { - core.warning(`Iteration field "${fieldName}" has no configured iterations`); - continue; - } - // Try to find iteration by title (case-insensitive match) - const iteration = field.configuration.iterations.find(iter => iter.title.toLowerCase() === String(fieldValue).toLowerCase()); - if (!iteration) { - const availableIterations = field.configuration.iterations.map(i => i.title).join(", "); - core.warning(`Iteration "${fieldValue}" not found in field "${fieldName}". Available iterations: ${availableIterations}`); - continue; - } - valueToSet = { iterationId: iteration.id }; - } else if (field.options) { - let option = field.options.find(o => o.name === fieldValue); - if (!option) { - // GitHub's GraphQL API does not support adding new options to existing single-select fields - // The updateProjectV2Field mutation does not exist - users must add options manually via UI - const availableOptions = field.options.map(o => o.name).join(", "); - core.warning(`Option "${fieldValue}" not found in field "${fieldName}". Available options: ${availableOptions}. To add this option, please update the field manually in the GitHub Projects UI.`); - continue; - } - valueToSet = { singleSelectOptionId: option.id }; - } else valueToSet = { text: String(fieldValue) }; - await github.graphql( - "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", - { projectId, itemId, fieldId: field.id, value: valueToSet } - ); - } + if (output.fields && Object.keys(output.fields).length > 0) { + await applyFieldUpdates(github, projectId, itemId, output.fields); } core.setOutput("item-id", itemId); @@ -1395,8 +1400,7 @@ async function main(config = {}, githubClient = null) { viewsCreated = true; core.info(`Creating ${configuredViews.length} configured view(s) on project: ${firstProjectUrl}`); - for (let i = 0; i < configuredViews.length; i++) { - const viewConfig = configuredViews[i]; + for (const [i, viewConfig] of configuredViews.entries()) { try { // Create a synthetic output item for view creation const viewOutput = { @@ -1447,4 +1451,4 @@ async function main(config = {}, githubClient = null) { }; } -module.exports = { updateProject, parseProjectInput, main }; +module.exports = { updateProject, parseProjectInput, main, normalizeUpdateProjectOutput, summarizeProjectsV2, summarizeEmptyProjectsV2List }; diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index d6a2c5a7d47..abd99c8553e 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -3,6 +3,9 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vite let updateProject; let parseProjectInput; let updateProjectHandlerFactory; +let normalizeUpdateProjectOutput; +let summarizeProjectsV2; +let summarizeEmptyProjectsV2List; const mockCore = { debug: vi.fn(), @@ -53,6 +56,9 @@ beforeAll(async () => { updateProject = exports.updateProject; parseProjectInput = exports.parseProjectInput; updateProjectHandlerFactory = exports.main; + normalizeUpdateProjectOutput = exports.normalizeUpdateProjectOutput; + summarizeProjectsV2 = exports.summarizeProjectsV2; + summarizeEmptyProjectsV2List = exports.summarizeEmptyProjectsV2List; // Call main to execute the module if (exports.main) { await exports.main(); @@ -2190,3 +2196,146 @@ describe("update_project handler: target_repo allowed-repos validation", () => { expect(result.success).toBe(true); }); }); + +describe("normalizeUpdateProjectOutput", () => { + it("returns non-object values unchanged", () => { + expect(normalizeUpdateProjectOutput(null)).toBeNull(); + expect(normalizeUpdateProjectOutput(undefined)).toBeUndefined(); + expect(normalizeUpdateProjectOutput("string")).toBe("string"); + expect(normalizeUpdateProjectOutput(42)).toBe(42); + }); + + it("normalizes camelCase contentType to content_type", () => { + const result = normalizeUpdateProjectOutput({ contentType: "issue" }); + expect(result.content_type).toBe("issue"); + }); + + it("normalizes camelCase contentNumber to content_number", () => { + const result = normalizeUpdateProjectOutput({ contentNumber: 42 }); + expect(result.content_number).toBe(42); + }); + + it("normalizes camelCase targetRepo to target_repo", () => { + const result = normalizeUpdateProjectOutput({ targetRepo: "org/repo" }); + expect(result.target_repo).toBe("org/repo"); + }); + + it("normalizes camelCase draftTitle to draft_title", () => { + const result = normalizeUpdateProjectOutput({ draftTitle: "My Draft" }); + expect(result.draft_title).toBe("My Draft"); + }); + + it("normalizes camelCase draftBody to draft_body", () => { + const result = normalizeUpdateProjectOutput({ draftBody: "Body text" }); + expect(result.draft_body).toBe("Body text"); + }); + + it("normalizes camelCase draftIssueId to draft_issue_id", () => { + const result = normalizeUpdateProjectOutput({ draftIssueId: "aw_abc123" }); + expect(result.draft_issue_id).toBe("aw_abc123"); + }); + + it("normalizes camelCase temporaryId to temporary_id", () => { + const result = normalizeUpdateProjectOutput({ temporaryId: "aw_xyz" }); + expect(result.temporary_id).toBe("aw_xyz"); + }); + + it("normalizes camelCase fieldDefinitions to field_definitions", () => { + const defs = [{ name: "Status", data_type: "TEXT" }]; + const result = normalizeUpdateProjectOutput({ fieldDefinitions: defs }); + expect(result.field_definitions).toBe(defs); + }); + + it("does not overwrite existing snake_case keys with camelCase aliases", () => { + const result = normalizeUpdateProjectOutput({ content_type: "issue", contentType: "pull_request" }); + expect(result.content_type).toBe("issue"); + }); + + it("handles a full camelCase payload", () => { + const result = normalizeUpdateProjectOutput({ + contentType: "draft_issue", + contentNumber: 7, + targetRepo: "org/repo", + draftTitle: "T", + draftBody: "B", + draftIssueId: "aw_id1", + temporaryId: "aw_tmp1", + fieldDefinitions: [], + }); + expect(result.content_type).toBe("draft_issue"); + expect(result.content_number).toBe(7); + expect(result.target_repo).toBe("org/repo"); + expect(result.draft_title).toBe("T"); + expect(result.draft_body).toBe("B"); + expect(result.draft_issue_id).toBe("aw_id1"); + expect(result.temporary_id).toBe("aw_tmp1"); + expect(result.field_definitions).toEqual([]); + }); +}); + +describe("summarizeProjectsV2", () => { + it("returns '(none)' for empty array", () => { + expect(summarizeProjectsV2([])).toBe("(none)"); + }); + + it("returns '(none)' for null or non-array input", () => { + expect(summarizeProjectsV2(null)).toBe("(none)"); + expect(summarizeProjectsV2(undefined)).toBe("(none)"); + }); + + it("formats a single open project correctly", () => { + const projects = [{ number: 42, title: "My Project" }]; + expect(summarizeProjectsV2(projects)).toBe("#42 My Project"); + }); + + it("formats a closed project with '(closed)' marker", () => { + const projects = [{ number: 10, title: "Old Project", closed: true }]; + expect(summarizeProjectsV2(projects)).toBe("#10 (closed) Old Project"); + }); + + it("joins multiple projects with semicolons", () => { + const projects = [ + { number: 1, title: "Alpha" }, + { number: 2, title: "Beta" }, + ]; + expect(summarizeProjectsV2(projects)).toBe("#1 Alpha; #2 Beta"); + }); + + it("filters out entries missing number or title", () => { + const projects = [{ number: 5, title: "Valid" }, { title: "No number" }, { number: 6 }, null]; + expect(summarizeProjectsV2(projects)).toBe("#5 Valid"); + }); + + it("respects the limit parameter", () => { + const projects = Array.from({ length: 5 }, (_, i) => ({ number: i + 1, title: `Project ${i + 1}` })); + const result = summarizeProjectsV2(projects, 3); + expect(result.split("; ").length).toBe(3); + }); +}); + +describe("summarizeEmptyProjectsV2List", () => { + it("returns '(none)' for empty list with no diagnostics", () => { + expect(summarizeEmptyProjectsV2List({})).toBe("(none)"); + }); + + it("includes totalCount context when items exist but none readable", () => { + const result = summarizeEmptyProjectsV2List({ totalCount: 3 }); + expect(result).toContain("totalCount=3"); + expect(result).toContain("0 readable project nodes"); + }); + + it("includes diagnostic counts in output", () => { + const result = summarizeEmptyProjectsV2List({ + totalCount: 0, + diagnostics: { rawNodesCount: 2, nullNodesCount: 2, rawEdgesCount: 1, nullEdgeNodesCount: 1 }, + }); + expect(result).toContain("nodes=2"); + expect(result).toContain("null=2"); + expect(result).toContain("edges=1"); + }); + + it("includes SSO hint in message when totalCount > 0", () => { + const result = summarizeEmptyProjectsV2List({ totalCount: 5, diagnostics: { rawNodesCount: 0, nullNodesCount: 0, rawEdgesCount: 0, nullEdgeNodesCount: 0 } }); + expect(result).toContain("SSO"); + }); +}); From 2db16c39bee0e27a0680a5f41d185c7c0cde9e17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:55:06 +0000 Subject: [PATCH 2/4] N/A Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/schemas/github-workflow.json | 294 ++++++++++++++++++---- 1 file changed, 245 insertions(+), 49 deletions(-) diff --git a/pkg/workflow/schemas/github-workflow.json b/pkg/workflow/schemas/github-workflow.json index ca272f38383..f670ca49089 100644 --- a/pkg/workflow/schemas/github-workflow.json +++ b/pkg/workflow/schemas/github-workflow.json @@ -1095,11 +1095,19 @@ "properties": { "branch_protection_rule": { "$comment": "https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#branch_protection_rule", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the branch_protection_rule event occurs. More than one activity type triggers this event.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "edited", "deleted"] @@ -1110,11 +1118,19 @@ }, "check_run": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#check-run-event-check_run", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the check_run event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/checks/runs.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "rerequested", "completed", "requested_action"] @@ -1125,11 +1141,19 @@ }, "check_suite": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#check-suite-event-check_suite", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the check_suite event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/checks/suites/.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["completed", "requested", "rerequested"] @@ -1140,31 +1164,55 @@ }, "create": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#create-event-create", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime someone creates a branch or tag, which triggers the create event. For information about the REST API, see https://developer.github.com/v3/git/refs/#create-a-reference." }, "delete": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#delete-event-delete", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime someone deletes a branch or tag, which triggers the delete event. For information about the REST API, see https://developer.github.com/v3/git/refs/#delete-a-reference." }, "deployment": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#deployment-event-deployment", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime someone creates a deployment, which triggers the deployment event. Deployments created with a commit SHA may not have a Git ref. For information about the REST API, see https://developer.github.com/v3/repos/deployments/." }, "deployment_status": { "$comment": "https://docs.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime a third party provides a deployment status, which triggers the deployment_status event. Deployments created with a commit SHA may not have a Git ref. For information about the REST API, see https://developer.github.com/v3/repos/deployments/#create-a-deployment-status." }, "discussion": { "$comment": "https://docs.github.com/en/actions/reference/events-that-trigger-workflows#discussion", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the discussion event occurs. More than one activity type triggers this event. For information about the GraphQL API, see https://docs.github.com/en/graphql/guides/using-the-graphql-api-for-discussions", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "edited", "deleted", "transferred", "pinned", "unpinned", "labeled", "unlabeled", "locked", "unlocked", "category_changed", "answered", "unanswered"] @@ -1175,11 +1223,19 @@ }, "discussion_comment": { "$comment": "https://docs.github.com/en/actions/reference/events-that-trigger-workflows#discussion_comment", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the discussion_comment event occurs. More than one activity type triggers this event. For information about the GraphQL API, see https://docs.github.com/en/graphql/guides/using-the-graphql-api-for-discussions", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "edited", "deleted"] @@ -1190,21 +1246,37 @@ }, "fork": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#fork-event-fork", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime when someone forks a repository, which triggers the fork event. For information about the REST API, see https://developer.github.com/v3/repos/forks/#create-a-fork." }, "gollum": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#gollum-event-gollum", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow when someone creates or updates a Wiki page, which triggers the gollum event." }, "issue_comment": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#issue-comment-event-issue_comment", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the issue_comment event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/issues/comments/.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "edited", "deleted"] @@ -1215,11 +1287,19 @@ }, "issues": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#issues-event-issues", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the issues event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/issues.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["opened", "edited", "deleted", "transferred", "pinned", "unpinned", "closed", "reopened", "assigned", "unassigned", "labeled", "unlabeled", "locked", "unlocked", "milestoned", "demilestoned"] @@ -1230,11 +1310,19 @@ }, "label": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#label-event-label", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the label event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/issues/labels/.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "edited", "deleted"] @@ -1245,11 +1333,19 @@ }, "merge_group": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#merge_group", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow when a pull request is added to a merge queue, which adds the pull request to a merge group. For information about the merge queue, see https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/merging-a-pull-request-with-a-merge-queue .", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["checks_requested"] @@ -1260,11 +1356,19 @@ }, "milestone": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#milestone-event-milestone", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the milestone event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/issues/milestones/.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "closed", "opened", "edited", "deleted"] @@ -1275,16 +1379,28 @@ }, "page_build": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#page-build-event-page_build", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime someone pushes to a GitHub Pages-enabled branch, which triggers the page_build event. For information about the REST API, see https://developer.github.com/v3/repos/pages/." }, "project": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#project-event-project", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the project event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/projects/.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "updated", "closed", "reopened", "edited", "deleted"] @@ -1295,11 +1411,19 @@ }, "project_card": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#project-card-event-project_card", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the project_card event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/projects/cards.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "moved", "converted", "edited", "deleted"] @@ -1310,11 +1434,19 @@ }, "project_column": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#project-column-event-project_column", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the project_column event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/projects/columns.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "updated", "moved", "deleted"] @@ -1325,7 +1457,11 @@ }, "public": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#public-event-public", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime someone makes a private repository public, which triggers the public event. For information about the REST API, see https://developer.github.com/v3/repos/#edit." }, "pull_request": { @@ -1341,7 +1477,11 @@ "type": "object", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": [ @@ -1412,11 +1552,19 @@ }, "pull_request_review": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#pull-request-review-event-pull_request_review", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the pull_request_review event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/pulls/reviews.\nNote: Workflows do not run on private base repositories when you open a pull request from a forked repository.\nWhen you create a pull request from a forked repository to the base repository, GitHub sends the pull_request event to the base repository and no pull request events occur on the forked repository.\nWorkflows don't run on forked repositories by default. You must enable GitHub Actions in the Actions tab of the forked repository.\nThe permissions for the GITHUB_TOKEN in forked repositories is read-only. For more information about the GITHUB_TOKEN, see https://help.github.com/en/articles/virtual-environments-for-github-actions.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["submitted", "edited", "dismissed"] @@ -1427,11 +1575,19 @@ }, "pull_request_review_comment": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#pull-request-review-comment-event-pull_request_review_comment", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime a comment on a pull request's unified diff is modified, which triggers the pull_request_review_comment event. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/pulls/comments.\nNote: Workflows do not run on private base repositories when you open a pull request from a forked repository.\nWhen you create a pull request from a forked repository to the base repository, GitHub sends the pull_request event to the base repository and no pull request events occur on the forked repository.\nWorkflows don't run on forked repositories by default. You must enable GitHub Actions in the Actions tab of the forked repository.\nThe permissions for the GITHUB_TOKEN in forked repositories is read-only. For more information about the GITHUB_TOKEN, see https://help.github.com/en/articles/virtual-environments-for-github-actions.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["created", "edited", "deleted"] @@ -1453,7 +1609,11 @@ "type": "object", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": [ @@ -1572,11 +1732,19 @@ }, "registry_package": { "$comment": "https://help.github.com/en/actions/reference/events-that-trigger-workflows#registry-package-event-registry_package", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime a package is published or updated. For more information, see https://help.github.com/en/github/managing-packages-with-github-packages.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["published", "updated"] @@ -1587,11 +1755,19 @@ }, "release": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#release-event-release", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the release event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/repos/releases/ in the GitHub Developer documentation.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["published", "unpublished", "created", "edited", "deleted", "prereleased", "released"] @@ -1602,12 +1778,20 @@ }, "status": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#status-event-status", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the status of a Git commit changes, which triggers the status event. For information about the REST API, see https://developer.github.com/v3/repos/statuses/." }, "watch": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#watch-event-watch", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "Runs your workflow anytime the watch event occurs. More than one activity type triggers this event. For information about the REST API, see https://developer.github.com/v3/activity/starring/." }, "workflow_call": { @@ -1724,11 +1908,19 @@ }, "workflow_run": { "$comment": "https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_run", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "This event occurs when a workflow run is requested or completed, and allows you to execute a workflow based on the finished result of another workflow. For example, if your pull_request workflow generates build artifacts, you can create a new workflow that uses workflow_run to analyze the results and add a comment to the original pull request.", "properties": { "types": { - "$ref": "#/definitions/types", + "allOf": [ + { + "$ref": "#/definitions/types" + } + ], "items": { "type": "string", "enum": ["requested", "completed", "in_progress"] @@ -1749,7 +1941,11 @@ }, "repository_dispatch": { "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows#external-events-repository_dispatch", - "$ref": "#/definitions/eventObject", + "allOf": [ + { + "$ref": "#/definitions/eventObject" + } + ], "description": "You can use the GitHub API to trigger a webhook event called repository_dispatch when you want to trigger a workflow for activity that happens outside of GitHub. For more information, see https://developer.github.com/v3/repos/#create-a-repository-dispatch-event.\nTo trigger the custom repository_dispatch webhook event, you must send a POST request to a GitHub API endpoint and provide an event_type name to describe the activity type. To trigger a workflow run, you must also configure your workflow to use the repository_dispatch event." }, "schedule": { From e717ca3626949930c84518ab2892d62c832f7e98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:01:35 +0000 Subject: [PATCH 3/4] Address review feedback: null guards, case-insensitive select, inferFieldDataType helper, fix dead condition Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 51 +++++++++++++++++------ actions/setup/js/update_project.test.cjs | 53 +++++++++++++++++++++++- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index f09f2b48f86..9bee92a2522 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -403,6 +403,11 @@ async function findExistingItemByContentId(github, projectId, contentId) { { projectId, after: endCursor } ); + if (!result?.node?.items) { + core.warning(`Project ${projectId} not found or inaccessible; stopping item search.`); + break; + } + const found = result.node.items.nodes.find(item => item.content?.id === contentId); if (found) return found; @@ -413,6 +418,27 @@ async function findExistingItemByContentId(github, projectId, contentId) { return null; } +/** + * Infer the expected GraphQL data type for a project field based on its name and value. + * @param {string} fieldName - Field name from YAML + * @param {unknown} fieldValue - Field value from YAML + * @param {RegExp} datePattern - Pattern to validate date values (YYYY-MM-DD) + * @returns {"DATE" | "TEXT" | "SINGLE_SELECT"} + */ +function inferFieldDataType(fieldName, fieldValue, datePattern) { + const isDateField = fieldName.toLowerCase().includes("date"); + // "classification" is always treated as a free-text field rather than single-select; + // pipe-delimited values ("A|B|C") also signal a free-text field storing multi-option strings. + const isTextField = fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|")); + if (isDateField && typeof fieldValue === "string" && datePattern.test(fieldValue)) { + return "DATE"; + } + if (isTextField) { + return "TEXT"; + } + return "SINGLE_SELECT"; +} + /** * Apply field value updates to a project item, creating fields as needed. * @param {Object} github - GitHub client (Octokit instance) @@ -436,16 +462,9 @@ async function applyFieldUpdates(github, projectId, itemId, fields) { continue; } - const isDateField = fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date"); - const isTextField = fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|")); - let expectedDataType; - if (isDateField && typeof fieldValue === "string" && datePattern.test(fieldValue)) { - expectedDataType = "DATE"; - } else if (isTextField) { - expectedDataType = "TEXT"; - } else { - expectedDataType = "SINGLE_SELECT"; - } + const expectedDataType = inferFieldDataType(fieldName, fieldValue, datePattern); + const isDateField = expectedDataType === "DATE" || fieldName.toLowerCase().includes("date"); + const isTextField = expectedDataType === "TEXT"; if (checkFieldTypeMismatch(fieldName, field, expectedDataType)) { continue; @@ -547,6 +566,11 @@ async function applyFieldUpdates(github, projectId, itemId, fields) { } } + if (!field) { + core.warning(`Field "${fieldName}" could not be created or resolved; skipping.`); + continue; + } + let valueToSet; if (field.dataType === "DATE") { valueToSet = { date: String(fieldValue) }; @@ -570,7 +594,7 @@ async function applyFieldUpdates(github, projectId, itemId, fields) { } valueToSet = { iterationId: iteration.id }; } else if (field.options) { - const option = field.options.find(o => o.name === fieldValue); + const option = field.options.find(o => o.name.toLowerCase() === String(fieldValue).toLowerCase()); if (!option) { const availableOptions = field.options.map(o => o.name).join(", "); core.warning(`Option "${fieldValue}" not found in field "${fieldName}". Available options: ${availableOptions}. To add this option, please update the field manually in the GitHub Projects UI.`); @@ -1139,6 +1163,9 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = }`; const contentResult = await github.graphql(contentQuery, { owner: contentOwner, repo: targetRepo, number: contentNumber }); const contentData = contentType === "Issue" ? contentResult.repository.issue : contentResult.repository.pullRequest; + if (!contentData) { + throw new Error(`${ERR_VALIDATION}: ${contentType} #${contentNumber} not found in ${contentOwner}/${targetRepo}.`); + } const contentId = contentData.id; const existingItem = await findExistingItemByContentId(github, projectId, contentId); @@ -1451,4 +1478,4 @@ async function main(config = {}, githubClient = null) { }; } -module.exports = { updateProject, parseProjectInput, main, normalizeUpdateProjectOutput, summarizeProjectsV2, summarizeEmptyProjectsV2List }; +module.exports = { updateProject, parseProjectInput, main, normalizeUpdateProjectOutput, summarizeProjectsV2, summarizeEmptyProjectsV2List, inferFieldDataType }; diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index abd99c8553e..7c240a538d2 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -6,6 +6,7 @@ let updateProjectHandlerFactory; let normalizeUpdateProjectOutput; let summarizeProjectsV2; let summarizeEmptyProjectsV2List; +let inferFieldDataType; const mockCore = { debug: vi.fn(), @@ -59,6 +60,7 @@ beforeAll(async () => { normalizeUpdateProjectOutput = exports.normalizeUpdateProjectOutput; summarizeProjectsV2 = exports.summarizeProjectsV2; summarizeEmptyProjectsV2List = exports.summarizeEmptyProjectsV2List; + inferFieldDataType = exports.inferFieldDataType; // Call main to execute the module if (exports.main) { await exports.main(); @@ -2336,6 +2338,55 @@ describe("summarizeEmptyProjectsV2List", () => { it("includes SSO hint in message when totalCount > 0", () => { const result = summarizeEmptyProjectsV2List({ totalCount: 5, diagnostics: { rawNodesCount: 0, nullNodesCount: 0, rawEdgesCount: 0, nullEdgeNodesCount: 0 } }); - expect(result).toContain("SSO"); + expect(result).toContain("totalCount=5"); + expect(result).toContain("0 readable project nodes"); + }); +}); + +describe("inferFieldDataType", () => { + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + + it("returns DATE for a date field name with valid date value", () => { + expect(inferFieldDataType("start_date", "2024-01-15", datePattern)).toBe("DATE"); + }); + + it("returns DATE for field containing 'date' in name with valid date value", () => { + expect(inferFieldDataType("due_date", "2024-06-30", datePattern)).toBe("DATE"); + }); + + it("returns SINGLE_SELECT for date field name with non-date value", () => { + expect(inferFieldDataType("start_date", "Q1", datePattern)).toBe("SINGLE_SELECT"); + }); + + it("returns TEXT for 'classification' field regardless of value", () => { + expect(inferFieldDataType("classification", "some-value", datePattern)).toBe("TEXT"); + }); + + it("returns TEXT for 'Classification' (case-insensitive match)", () => { + expect(inferFieldDataType("Classification", "A", datePattern)).toBe("TEXT"); + }); + + it("returns TEXT for pipe-delimited value", () => { + expect(inferFieldDataType("status", "A|B|C", datePattern)).toBe("TEXT"); + }); + + it("returns SINGLE_SELECT for non-date non-text field", () => { + expect(inferFieldDataType("priority", "High", datePattern)).toBe("SINGLE_SELECT"); + }); + + it("returns SINGLE_SELECT for numeric field value", () => { + expect(inferFieldDataType("score", 42, datePattern)).toBe("SINGLE_SELECT"); + }); + + it("returns SINGLE_SELECT for boolean field value", () => { + expect(inferFieldDataType("active", true, datePattern)).toBe("SINGLE_SELECT"); + }); + + it("returns DATE for 'updated_date' with valid date value", () => { + expect(inferFieldDataType("updated_date", "2025-12-31", datePattern)).toBe("DATE"); + }); + + it("returns SINGLE_SELECT for date field name with invalid date format", () => { + expect(inferFieldDataType("end_date", "2024/06/30", datePattern)).toBe("SINGLE_SELECT"); }); }); From 20bb21c3c631a7935b71005c910bdac614cbde9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:04:17 +0000 Subject: [PATCH 4/4] Simplify isDateField: drop redundant OR with expectedDataType check Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 9bee92a2522..d06d1cb9809 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -463,7 +463,7 @@ async function applyFieldUpdates(github, projectId, itemId, fields) { } const expectedDataType = inferFieldDataType(fieldName, fieldValue, datePattern); - const isDateField = expectedDataType === "DATE" || fieldName.toLowerCase().includes("date"); + const isDateField = fieldName.toLowerCase().includes("date"); const isTextField = expectedDataType === "TEXT"; if (checkFieldTypeMismatch(fieldName, field, expectedDataType)) {