Skip to content

Commit

Permalink
Merge pull request #3917 from Royal-Navy/fix/projects-move-issues-aut…
Browse files Browse the repository at this point in the history
…omation

build(GHA): Fix move labelled issues workflow
  • Loading branch information
m7kvqbe1 authored Oct 11, 2024
2 parents 642e660 + e481e7d commit e4a7cab
Show file tree
Hide file tree
Showing 4 changed files with 281 additions and 11 deletions.
25 changes: 16 additions & 9 deletions .github/workflows/project_issues.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
name: Project Automation - move issues
name: Project Automation - Move Issues

on:
issues:
types: [labeled]

env:
PAT_TOKEN: ${{ secrets.GH_ISSUES_TOKEN }}
PROJECT_URL: https://github.com/orgs/Royal-Navy/projects/9

jobs:
Move_labelled_issues:
Move_labeled_issues:
runs-on: ubuntu-latest
if: github.event_name == 'issues'
steps:
- name: Move small/med labeled issues to Candidates column
uses: Royal-Navy/design-system-moveissue-action@master
- name: Checkout repository
uses: actions/checkout@v4

- name: Move issues to project column
uses: actions/github-script@v7
with:
action-token: '${{ secrets.GHA_ISSUES_TOKEN }}'
project-url: 'https://github.com/Royal-Navy/design-system/projects/6'
column-name: 'Candidates for Ready'
label-name: 'Size: Small,Size: Medium'
columns-to-ignore: 'Ready,In Progress,In Review,Done'
github-token: ${{ secrets.GH_ISSUES_TOKEN }}
script: |
const { moveIssues } = await import('${{ github.workspace }}/scripts/github-actions/moveIssues.mjs')
await moveIssues({ github, context, core })
2 changes: 1 addition & 1 deletion .github/workflows/project_notify.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Project Automation - notify
name: Project Automation - Notify

on:
schedule:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/project_stale_issues.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Project Automation - stale issues
name: Project Automation - Stale Issues

on:
schedule:
Expand Down
263 changes: 263 additions & 0 deletions scripts/github-actions/moveIssues.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
const TARGET_LABELS = ['Size: Small', 'Size: Medium']
const TARGET_COLUMN = 'Candidates for Ready'
const IGNORED_COLUMNS = ['Ready', 'In Progress', 'In Review', 'Done']

const parseProjectUrl = (url) => {
const parts = url.split('/')

return {
orgName: parts[parts.length - 3],
projectUrl: url,
}
}

const fetchAllProjects = async (
github,
orgName,
cursor = null,
allProjects = []
) => {
const query = `
query($orgName: String!, $cursor: String) {
organization(login: $orgName) {
projectsV2(first: 100, after: $cursor) {
nodes { id, url, number }
pageInfo { hasNextPage, endCursor }
}
}
}
`

const {
organization: { projectsV2 },
} = await github.graphql(query, { orgName, cursor })

const updatedProjects = [...allProjects, ...projectsV2.nodes]

if (projectsV2.pageInfo.hasNextPage) {
return fetchAllProjects(
github,
orgName,
projectsV2.pageInfo.endCursor,
updatedProjects
)
}

return updatedProjects
}

const getProjectData = async (github, projectUrl) => {
const { orgName, projectUrl: fullProjectUrl } = parseProjectUrl(projectUrl)
const allProjects = await fetchAllProjects(github, orgName)
const project = allProjects.find((p) => p.url === fullProjectUrl)

if (!project) {
throw new Error(`Project not found: ${fullProjectUrl}`)
}

return project
}

const fetchProjectItems = async (
github,
projectId,
cursor = null,
allItems = []
) => {
const query = `
query($projectId: ID!, $cursor: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 100, after: $cursor) {
nodes {
id
content { ... on Issue { id } }
fieldValues(first: 8) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field { ... on ProjectV2SingleSelectField { name } }
}
}
}
}
pageInfo { hasNextPage, endCursor }
}
}
}
}
`

const result = await github.graphql(query, { projectId, cursor })
const updatedItems = [...allItems, ...result.node.items.nodes]

if (result.node.items.pageInfo.hasNextPage) {
return fetchProjectItems(
github,
projectId,
result.node.items.pageInfo.endCursor,
updatedItems
)
}

return updatedItems
}

const getIssueItemData = async (github, projectId, issueId) => {
const allItems = await fetchProjectItems(github, projectId)
return allItems.find((item) => item.content && item.content.id === issueId)
}

const updateIssueStatus = async (
github,
projectId,
itemId,
statusFieldId,
statusOptionId
) => {
const mutation = `
mutation($projectId: ID!, $itemId: ID!, $statusFieldId: ID!, $statusOptionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $statusFieldId
value: { singleSelectOptionId: $statusOptionId }
}
) {
projectV2Item { id }
}
}
`

await github.graphql(mutation, {
projectId,
itemId,
statusFieldId,
statusOptionId,
})
}

const addIssueToProject = async (github, projectId, issueId) => {
const mutation = `
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item { id }
}
}
`

const result = await github.graphql(mutation, {
projectId,
contentId: issueId,
})

return result.addProjectV2ItemById.item
}

const getStatusField = async (github, projectId) => {
const query = `
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options { id, name }
}
}
}
}
}
}
`

const result = await github.graphql(query, { projectId })

return result.node.fields.nodes.find((field) => field.name === 'Status')
}

const getCurrentStatus = (issueItemData) => {
return issueItemData.fieldValues?.nodes.find(
(node) => node.field?.name === 'Status'
)?.name
}

const validateIssue = (issue) => {
if (!issue || !issue.node_id) {
throw new Error('Invalid or missing issue object')
}

if (!issue.labels.some((label) => TARGET_LABELS.includes(label.name))) {
throw new Error(`Issue #${issue.number} does not have a target label`)
}

return true
}

const getTargetStatusOption = (statusField) => {
const targetStatusOption = statusField.options.find(
(option) => option.name === TARGET_COLUMN
)

if (!targetStatusOption) {
throw new Error(`Target status "${TARGET_COLUMN}" not found in project`)
}

return targetStatusOption
}

const processIssueItem = async (github, projectData, issue) => {
const statusField = await getStatusField(github, projectData.id)
const targetStatusOption = getTargetStatusOption(statusField)

let issueItemData = await getIssueItemData(
github,
projectData.id,
issue.node_id
)

if (!issueItemData) {
issueItemData = await addIssueToProject(
github,
projectData.id,
issue.node_id
)
}

const currentStatus = getCurrentStatus(issueItemData)

if (IGNORED_COLUMNS.includes(currentStatus)) {
console.log(
`Issue #${issue.number} is in an ignored column (${currentStatus}). Skipping.`
)
return
}

await updateIssueStatus(
github,
projectData.id,
issueItemData.id,
statusField.id,
targetStatusOption.id
)

console.log(`Moved issue #${issue.number} to "${TARGET_COLUMN}"`)
}

export const moveIssues = async ({ github, context, core }) => {
const issue = context.payload.issue

try {
if (!validateIssue(issue)) {
return
}

const projectData = await getProjectData(github, process.env.PROJECT_URL)
await processIssueItem(github, projectData, issue)
} catch (error) {
core.setFailed(`Error moving issue: ${error.message}`)
}
}

0 comments on commit e4a7cab

Please sign in to comment.