diff --git a/.github/GithubAPI.js b/.github/GithubAPI.js new file mode 100644 index 0000000..b63677b --- /dev/null +++ b/.github/GithubAPI.js @@ -0,0 +1,190 @@ +const axios = require("axios"); + +module.exports = class GithubAPI { + constructor(owner) { + this.url = 'https://api.github.com/graphql'; + this.token = process.env.GITHUB_TOKEN; + console.log("Is there a github token?", !!this.token); + this.owner = owner; + } + + async query(query, variables) { + try { + const response = await axios.post(this.url, { + query, + variables + }, { + headers: { + Authorization: `Bearer ${this.token}` + } + }); + + if (response.data.errors) { + throw new Error(JSON.stringify(response.data.errors, null, 2)); + } + + return response.data; + } catch (error) { + console.error(error); + throw error; + } + } + + async getSourceAndTargetProjects({ sourceNumber, targetNumber }) { + const projectSubquery = ` + id + title + fields(first: 30) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + `; + const query = ` + query getSourceAndTargetProjectsIds($owner: String!, $source: Int!, $target: Int!) { + organization (login: $owner) { + source: projectV2(number: $source) { + ${projectSubquery} + } + target: projectV2(number: $target) { + ${projectSubquery} + } + } + } + `; + + const response = await this.query(query, { + owner: this.owner, + source: sourceNumber, + target: targetNumber, + }); + + const { source, target } = response.data.organization; + + if (!source) { + throw new Error(`Source project not found: ${sourceNumber}`); + } + + if (!target) { + throw new Error(`Target project not found: ${targetNumber}`); + } + + return { + sourceProject: source, + targetProject: target + }; + } + + async getProjectItems(projectId) { + const statusSubquery = ` + status: fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { + vaueId: id + value: name + valueOptionId: optionId + } + } + `; + // Subquery to get info about the status of the issue/PR on each project it belongs to + const projectItemsSubquery = ` + projectItems(first: 10) { + nodes { + id + ${ statusSubquery } + project { + id + title + } + } + } + `; + const query = ` + query GetProjectItems($projectId: ID!, $cursor: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 50, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + ${ statusSubquery } + content { + __typename + ... on Issue { + id + title + ${ projectItemsSubquery } + } + ... on PullRequest { + id + title + ${ projectItemsSubquery } + } + } + } + } + } + } + } + `; + + const _getProjectItems = async (cursor = null, items = []) => { + const response = await this.query(query, { + projectId, + cursor + }); + + const { nodes, pageInfo } = response.data.node.items; + + items.push(...nodes); + + if (pageInfo.hasNextPage) { + return _getProjectItems(pageInfo.endCursor, items); + } + + return items; + }; + + return _getProjectItems(); + } + + async updateProjectItemsFields(items) { + const query = ` + mutation UpdateProjectItemField($projectId: ID!, $itemId: ID!, $fieldId: ID!, $newValue: ProjectV2FieldValue!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: $newValue + }) { + projectV2Item { + id + } + } + } + `; + + const BATCH_SIZE = 10; + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE); + + await Promise.all(batch.map(async item => { + await this.query(query, { + projectId: item.projectId, + itemId: item.projectItemId, + fieldId: item.fieldId, + newValue: item.newValue + }); + })); + } + } +} \ No newline at end of file diff --git a/.github/githubUtils.js b/.github/githubUtils.js new file mode 100644 index 0000000..7650406 --- /dev/null +++ b/.github/githubUtils.js @@ -0,0 +1,78 @@ +const GithubAPI = require('./GithubAPI'); + +const synchronizeProjectsStatuses = async (github) => { + const sourceNumber = 15; //"Iteration backlog"; + const targetNumber = 29; // KDS Roadmap + const getTargetStatus = (sourceStatus) => { + const statusMap = { + "IN REVIEW": "IN REVIEW", + "NEEDS QA": "IN REVIEW", + "DONE": "DONE", + // All other statuses are mapped to "BACKLOG" + }; + + const targetStatus = Object.keys(statusMap).find((key) => + sourceStatus.toUpperCase().includes(key) + ); + + return targetStatus ? statusMap[targetStatus] : "BACKLOG"; + } + + const githubAPI = new GithubAPI("LearningEquality", github); + const { sourceProject, targetProject } = await githubAPI.getSourceAndTargetProjects({ sourceNumber, targetNumber }); + + console.log("sourceName", sourceNumber); + console.log("sourceProject", sourceProject); + const targetStatusField = targetProject.fields.nodes.find((field) => field.name === "Status"); + + const targetProjectItems = await githubAPI.getProjectItems(targetProject.id); + const itemsToUpdate = targetProjectItems.filter((item) => { + const sourceProjectItem = item.content.projectItems?.nodes.find((sourceItem) => ( + sourceItem.project.id === sourceProject.id + )); + if (!sourceProjectItem) { + return false; + } + + const sourceStatus = sourceProjectItem.status?.value; + if (!sourceStatus) { + return false; + } + + const currentTargetStatusId = item.status?.valueOptionId; + const newTargetStatus = getTargetStatus(sourceStatus); + const newTargetStatusId = targetStatusField.options.find((option) => option.name.toUpperCase().includes(newTargetStatus))?.id; + + if (!newTargetStatusId) { + console.log(`Status "${newTargetStatus}" not found in target project`); + return false; + } + + item.newStatusId = newTargetStatusId; + + return newTargetStatusId !== currentTargetStatusId; + }); + + if (itemsToUpdate.length === 0) { + console.log("No items to update"); + return; + } + + const itemsPayload = itemsToUpdate.map(item => ({ + projectId: targetProject.id, + projectItemId: item.id, + fieldId: targetStatusField.id, + newValue: { + singleSelectOptionId: item.newStatusId + } + })) + + console.log(`Updating ${itemsToUpdate.length} items...`); + console.log("Items payload", itemsPayload); + await githubAPI.updateProjectItemsFields(itemsPayload); + console.log("Items updated successfully"); +} + +module.exports = { + synchronizeProjectsStatuses +}; diff --git a/.github/workflows/sync_kds_roadmap_statuses.yml b/.github/workflows/sync_kds_roadmap_statuses.yml new file mode 100644 index 0000000..2714253 --- /dev/null +++ b/.github/workflows/sync_kds_roadmap_statuses.yml @@ -0,0 +1,40 @@ +name: Sync KDS Roadmap Project Statuses + +on: + schedule: + - cron: "0 * * * *" # Run every hour + workflow_dispatch: + +jobs: + sync-projects: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install axios + run: npm install axios + + - uses: tibdex/github-app-token@v1 + id: generate-token + with: + app_id: ${{ secrets.LE_BOT_APP_ID }} + private_key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + + - name: Check and Sync Project Statuses + uses: actions/github-script@v7 + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + console.log("github", github); + const { synchronizeProjectsStatuses } = require('./.github/githubUtils.js'); + synchronizeProjectsStatuses(github);