[Manual Test] Create_new_instance_of_-Actor_Framework-_project #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: "Process Manual Test Issues" | |
on: | |
issues: | |
# We do NOT trigger on 'unlabeled' to avoid potential errors if label is removed | |
types: [opened, edited, labeled] | |
concurrency: | |
group: manual-test-${{ github.event.issue.number }} | |
cancel-in-progress: true | |
jobs: | |
process-manual-test: | |
runs-on: ubuntu-latest | |
steps: | |
################################################################ | |
# STEP 1: Capture the event payload (so we can do custom logic) | |
################################################################ | |
- name: Capture GitHub event payload | |
run: | | |
echo "GITHUB_EVENT_PAYLOAD=$(echo '${{ toJson(github.event) }}' | base64 -w0)" >> $GITHUB_ENV | |
################################################################ | |
# STEP 2: Quick label/title checks => skip if: | |
# - 'manual-test' label missing | |
# - Issue title does not start with "[Manual Test]" | |
################################################################ | |
- name: Preliminary label/title check | |
id: label_and_title | |
run: | | |
EVENT_JSON="$(echo "$GITHUB_EVENT_PAYLOAD" | base64 -d)" | |
LABELS="$(echo "$EVENT_JSON" | jq -r '.issue.labels[].name')" | |
ISSUE_TITLE="$(echo "$EVENT_JSON" | jq -r '.issue.title')" | |
echo "Labels found:" | |
echo "$LABELS" | |
echo "Issue title: $ISSUE_TITLE" | |
# Check exact label | |
echo "$LABELS" | grep -xq 'manual-test' || { | |
echo "No 'manual-test' label. Skipping workflow." | |
echo "::set-output name=skip::true" | |
exit 0 | |
} | |
# Check title prefix | |
if [[ "$ISSUE_TITLE" != "[Manual Test]"* ]]; then | |
echo "Issue title does not start with '[Manual Test]'. Skipping workflow." | |
echo "::set-output name=skip::true" | |
exit 0 | |
fi | |
echo "::set-output name=skip::false" | |
- name: Stop if skipping | |
if: steps.label_and_title.outputs.skip == 'true' | |
run: | | |
echo "Skipping further steps..." | |
exit 0 | |
################################################################ | |
# STEP 3: Parse required form fields (blank-line safe) | |
################################################################ | |
- name: Parse form fields | |
id: parse_body | |
uses: actions/github-script@v6 | |
with: | |
script: | | |
const issueBody = `${process.env.ISSUE_BODY || ''}`.trim() | |
|| `${github.event.issue.body || ''}`.trim(); | |
// Retrieve the next non-blank line after a label | |
function getField(fieldLabel) { | |
const lines = issueBody.split('\n'); | |
for (let i = 0; i < lines.length; i++) { | |
const line = lines[i].trim(); | |
if (line.includes(fieldLabel)) { | |
let j = i + 1; | |
while (j < lines.length) { | |
const nextLine = lines[j].trim(); | |
if (nextLine !== '') { | |
return nextLine; | |
} | |
j++; | |
} | |
return null; | |
} | |
} | |
return null; | |
} | |
// The fields we consider "required" | |
const requiredFields = { | |
test_id: '🧪 Select a Test', | |
test_result: '✅ Test Result', | |
os_used: '🖥️ Operating System', | |
labview_version: '🧰 LabVIEW Version Used', | |
labview_bitness: '💻 LabVIEW Bitness' | |
}; | |
// Parse them | |
let parsed = {}; | |
for (const [key, label] of Object.entries(requiredFields)) { | |
const val = getField(label); | |
if (!val) { | |
core.setFailed(`Missing or invalid field: '${key}' (label: '${label}')`); | |
return; | |
} | |
parsed[key] = val; | |
} | |
// Validate test_result | |
const validResults = ['Passed', 'Failed', 'Needs Review']; | |
if (!validResults.includes(parsed.test_result)) { | |
core.setFailed(`Invalid 'test_result': '${parsed.test_result}'. Must be Passed, Failed, or Needs Review.`); | |
return; | |
} | |
// Optional "notes" | |
const notesLabel = '📝 Notes or Screenshots (optional)'; | |
parsed.notes = getField(notesLabel) || ''; | |
// Output | |
core.setOutput('test_id', parsed.test_id); | |
core.setOutput('test_result', parsed.test_result); | |
core.setOutput('os_used', parsed.os_used); | |
core.setOutput('labview_version', parsed.labview_version); | |
core.setOutput('labview_bitness', parsed.labview_bitness); | |
core.setOutput('notes', parsed.notes); | |
################################################################ | |
# STEP 4: Extract numeric test ID + Estimate from a table row | |
################################################################ | |
- name: Extract numeric TestID & Estimate | |
id: parse_table | |
uses: actions/github-script@v6 | |
with: | |
script: | | |
const aliasRaw = core.getInput('test_id', { required: true }); | |
const body = github.event.issue.body || ''; | |
// Convert underscores => spaces, for case-insensitive compare | |
const aliasCompare = aliasRaw.replace(/_/g, ' ').toLowerCase(); | |
let numericID = null; | |
let estimateVal = null; | |
const lines = body.split('\n'); | |
for (const row of lines) { | |
const line = row.trim(); | |
// Suppose each relevant row in the table starts with "|" | |
if (!line.startsWith('|')) continue; | |
const cells = line.split('|').map(x => x.trim()).filter(Boolean); | |
// For example: | Actor Creation | *20 Min* | [Some Link](...) | | |
// Might see cells like: [0]=, [1]=Actor Creation, [2]=*20 Min*, [3]=[Link](URL)... | |
if (cells.length < 4) continue; // skip invalid table lines | |
// cells[1] => "Actor Creation" (title) | |
// cells[2] => "*20 Min*" (estimate) | |
// cells[3] => "[Some link](...)" | |
const possibleTitle = cells[1].toLowerCase(); | |
if (possibleTitle === aliasCompare) { | |
// parse estimate | |
const estRaw = cells[2] || ''; | |
const estNoAsterisk = estRaw.replace(/\*/g, '').trim(); | |
if (!estNoAsterisk) { | |
core.setFailed(`No 'Estimate' found for test alias '${aliasRaw}'.`); | |
return; | |
} | |
// We'll attempt to parse it as a number if it looks numeric | |
// If it's something like "20 Min", we just strip the alpha. | |
// For a pure numeric field, let's parse out digits: | |
const numMatch = estNoAsterisk.match(/\d+/); | |
if (!numMatch) { | |
core.setFailed(`Estimate does not contain a numeric portion: '${estNoAsterisk}'`); | |
return; | |
} | |
estimateVal = parseInt(numMatch[0], 10); | |
if (isNaN(estimateVal)) { | |
core.setFailed(`Cannot parse estimate as number: '${estNoAsterisk}'`); | |
return; | |
} | |
// parse link => 7-digit .md | |
const linkCell = cells[3]; | |
const linkMatch = linkCell.match(/\(([^)]+)\)/); | |
if (!linkMatch) { | |
core.setFailed(`No link found in table for alias='${aliasRaw}'.`); | |
return; | |
} | |
const linkURL = linkMatch[1]; | |
const segs = linkURL.split('/'); | |
const lastSeg = segs[segs.length - 1]; | |
if (!lastSeg.endsWith('.md')) { | |
core.setFailed(`Link not referencing a .md file => '${linkURL}'`); | |
return; | |
} | |
const baseName = lastSeg.replace('.md', ''); | |
if (!/^\d{7}$/.test(baseName)) { | |
core.setFailed(`Not a 7-digit .md => '${lastSeg}'`); | |
return; | |
} | |
numericID = baseName; | |
break; | |
} | |
} | |
if (!numericID) { | |
core.setFailed(`No matching row found for test alias '${aliasRaw}'.`); | |
return; | |
} | |
// Expose as outputs | |
core.setOutput('numeric_id', numericID); | |
core.setOutput('estimate_num', estimateVal.toString()); | |
################################################################ | |
# STEP 5a: Find the previous [Manual Test] issue for same author | |
################################################################ | |
- name: Find previous [Manual Test] issue | |
id: find_previous | |
uses: actions/github-script@v6 | |
with: | |
script: | | |
const currentNum = github.event.issue.number; | |
const authorLogin = github.event.issue.user.login; | |
const { owner, repo } = context.repo; | |
// search for issues in this repo | |
// label=manual-test, author=..., in:title "[Manual Test]" | |
// sorted desc by created => exclude current | |
const q = `repo:${owner}/${repo} is:issue label:manual-test author:${authorLogin} in:title "[Manual Test]"`; | |
const searchResp = await github.rest.search.issuesAndPullRequests({ | |
q, | |
sort: 'created', | |
order: 'desc', | |
per_page: 10 | |
}); | |
const results = searchResp.data.items.filter(i => i.number !== currentNum); | |
if (!results.length) { | |
core.info("No previous [Manual Test] issue found for same author => no Start Date to set."); | |
core.setOutput('previous_issue_number', ''); | |
return; | |
} | |
const prevIssue = results[0]; | |
core.info(`Found previous issue #${prevIssue.number}, title="${prevIssue.title}"`); | |
core.setOutput('previous_issue_number', prevIssue.number.toString()); | |
################################################################ | |
# STEP 5b: Retrieve the previous issue's "End Date" | |
################################################################ | |
- name: Read previous End Date | |
id: read_end_date | |
if: steps.find_previous.outputs.previous_issue_number != '' | |
uses: actions/github-script@v6 | |
with: | |
script: | | |
const prevNum = parseInt(core.getInput('previous_issue_number'), 10); | |
const { owner, repo } = context.repo; | |
// fetch the node_id | |
const issueResp = await github.rest.issues.get({ | |
owner, | |
repo, | |
issue_number: prevNum | |
}); | |
const prevNodeId = issueResp.data.node_id; | |
if (!prevNodeId) { | |
core.notice(`No node_id for #${prevNum}, skipping 'End Date' retrieval.`); | |
return; | |
} | |
// find the project item(s) | |
const itemsQuery = ` | |
query($issueId: ID!) { | |
node(id: $issueId) { | |
... on Issue { | |
projectItems(first: 50) { | |
nodes { | |
id | |
project { | |
title | |
} | |
} | |
} | |
} | |
} | |
} | |
`; | |
const itemData = await github.graphql(itemsQuery, { issueId: prevNodeId }); | |
const items = itemData.node?.projectItems?.nodes || []; | |
let foundEnd = ''; | |
for (const it of items) { | |
const fQuery = ` | |
query($itemId: ID!) { | |
node(id: $itemId) { | |
... on ProjectV2Item { | |
fieldValues(first: 50) { | |
nodes { | |
__typename | |
... on ProjectV2ItemFieldDateValue { | |
field { | |
... on ProjectV2FieldCommon { | |
id | |
name | |
} | |
} | |
date | |
} | |
} | |
} | |
} | |
} | |
} | |
`; | |
const fResp = await github.graphql(fQuery, { itemId: it.id }); | |
const fieldNodes = fResp.node?.fieldValues?.nodes || []; | |
for (const fn of fieldNodes) { | |
if (fn.field?.name === "End Date" && fn.__typename === "ProjectV2ItemFieldDateValue") { | |
if (fn.date) { | |
foundEnd = fn.date; | |
break; | |
} | |
} | |
} | |
if (foundEnd) break; | |
} | |
core.info(`Previous #${prevNum} => End Date='${foundEnd}'`); | |
core.setOutput('prev_end_date', foundEnd); | |
################################################################ | |
# STEP 5c: Update current issue's fields | |
# (set Start Date, End Date if needed, plus TestID, etc.) | |
################################################################ | |
- name: Update project fields for current issue | |
id: update_fields | |
uses: actions/github-script@v6 | |
with: | |
script: | | |
// Gather inputs | |
const testIdNum = core.getInput('numeric_id'); // from parse_table | |
const estimateRaw = core.getInput('estimate_num'); | |
const testResult = core.getInput('test_result'); | |
const osUsed = core.getInput('os_used'); | |
const lvVers = core.getInput('labview_version'); | |
const lvBit = core.getInput('labview_bitness'); | |
const notes = core.getInput('notes'); | |
const prevEnd = core.getInput('prev_end_date'); // might be blank | |
const createdAt = github.event.issue.created_at; | |
const issueNodeId = github.event.issue.node_id; | |
if (!issueNodeId) { | |
core.setFailed("No node_id on current issue. Cannot update project items."); | |
return; | |
} | |
let estimateNum = null; | |
try { | |
estimateNum = parseFloat(estimateRaw); | |
} catch(e) { | |
// fallback | |
estimateNum = 0; | |
} | |
// 1) Query project items for this issue | |
const itemsQ = ` | |
query($id: ID!) { | |
node(id: $id) { | |
... on Issue { | |
projectItems(first: 50) { | |
nodes { | |
id | |
project { | |
id | |
title | |
} | |
} | |
} | |
} | |
} | |
} | |
`; | |
const resp = await github.graphql(itemsQ, { id: issueNodeId }); | |
const items = resp.node?.projectItems?.nodes || []; | |
if (!items.length) { | |
core.info("Issue is not assigned to any project => skipping field updates."); | |
return; | |
} | |
// 2) For each item, read current field values => update them | |
async function readFields(itemId) { | |
const qry = ` | |
query($itemId: ID!) { | |
node(id: $itemId) { | |
... on ProjectV2Item { | |
fieldValues(first: 50) { | |
nodes { | |
__typename | |
... on ProjectV2ItemFieldTextValue { | |
field { ... on ProjectV2FieldCommon { id name } } | |
text | |
} | |
... on ProjectV2ItemFieldSingleSelectValue { | |
field { ... on ProjectV2FieldCommon { id name } } | |
name | |
} | |
... on ProjectV2ItemFieldDateValue { | |
field { ... on ProjectV2FieldCommon { id name } } | |
date | |
} | |
... on ProjectV2ItemFieldNumberValue { | |
field { ... on ProjectV2FieldCommon { id name } } | |
number | |
} | |
} | |
} | |
} | |
} | |
} | |
`; | |
const r = await github.graphql(qry, { itemId }); | |
return r.node?.fieldValues?.nodes || []; | |
} | |
async function updateFieldValue(projectId, itemId, fieldId, valObj) { | |
const mut = ` | |
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $val: ProjectV2FieldValue!) { | |
updateProjectV2ItemFieldValue(input: { | |
projectId: $projectId, | |
itemId: $itemId, | |
fieldId: $fieldId, | |
value: $val | |
}) { | |
projectV2Item { id } | |
} | |
} | |
`; | |
await github.graphql(mut, { | |
projectId, | |
itemId, | |
fieldId, | |
val: valObj | |
}); | |
} | |
for (const it of items) { | |
core.info(`Updating itemId='${it.id}' in project='${it.project.title}'`); | |
const fieldNodes = await readFields(it.id); | |
// Build map => name -> {id, typeName, currentValue} | |
const map = {}; | |
for (const fn of fieldNodes) { | |
const fName = fn.field?.name; | |
if (!fName) continue; | |
let value; | |
if (fn.__typename === 'ProjectV2ItemFieldTextValue') { | |
value = fn.text; | |
} else if (fn.__typename === 'ProjectV2ItemFieldSingleSelectValue') { | |
value = fn.name; | |
} else if (fn.__typename === 'ProjectV2ItemFieldDateValue') { | |
value = fn.date; | |
} else if (fn.__typename === 'ProjectV2ItemFieldNumberValue') { | |
value = fn.number; | |
} | |
map[fName] = { | |
fieldId: fn.field.id, | |
typeName: fn.__typename, | |
current: value | |
}; | |
} | |
// Update "TestID" (Text) | |
if (map["TestID"]) { | |
await updateFieldValue(it.project.id, it.id, map["TestID"].fieldId, { | |
text: testIdNum | |
}); | |
} | |
// Update "LabVIEW Version" (Text) | |
if (map["LabVIEW Version"]) { | |
await updateFieldValue(it.project.id, it.id, map["LabVIEW Version"].fieldId, { | |
text: lvVers | |
}); | |
} | |
// "LabVIEW Bitness" (Text) | |
if (map["LabVIEW Bitness"]) { | |
await updateFieldValue(it.project.id, it.id, map["LabVIEW Bitness"].fieldId, { | |
text: lvBit | |
}); | |
} | |
// "Operating System" (Text) | |
if (map["Operating System"]) { | |
await updateFieldValue(it.project.id, it.id, map["Operating System"].fieldId, { | |
text: osUsed | |
}); | |
} | |
// "Test Result" (Single Select) | |
if (map["Test Result"]) { | |
// If it's singleSelectValue => { singleSelectValue: testResult } | |
// If it was text => { text: testResult } | |
if (map["Test Result"].typeName === 'ProjectV2ItemFieldSingleSelectValue') { | |
await updateFieldValue(it.project.id, it.id, map["Test Result"].fieldId, { | |
singleSelectValue: testResult | |
}); | |
} else { | |
// fallback if it was text | |
await updateFieldValue(it.project.id, it.id, map["Test Result"].fieldId, { | |
text: testResult | |
}); | |
} | |
} | |
// "Notes" (Text) | |
if (map["Notes"]) { | |
await updateFieldValue(it.project.id, it.id, map["Notes"].fieldId, { | |
text: notes | |
}); | |
} | |
// "Estimate" (Number) | |
// parse estimateVal from estimateNum | |
if (map["Estimate"]) { | |
await updateFieldValue(it.project.id, it.id, map["Estimate"].fieldId, { | |
number: estimateNum | |
}); | |
} | |
// "End Date" => set once only if blank | |
if (map["End Date"]) { | |
if (!map["End Date"].current) { | |
await updateFieldValue(it.project.id, it.id, map["End Date"].fieldId, { | |
date: createdAt // use issue creation timestamp | |
}); | |
} | |
} | |
// "Start Date" => from prev_end_date if any, only if blank | |
if (map["Start Date"] && prevEnd) { | |
if (!map["Start Date"].current) { | |
await updateFieldValue(it.project.id, it.id, map["Start Date"].fieldId, { | |
date: prevEnd | |
}); | |
} | |
} | |
core.info("Item updated successfully."); | |
} | |
core.info("Finished updating fields for all assigned project items."); | |
- name: Done | |
run: echo "Workflow completed successfully." |