action-project-board-status.yml 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. name: Project Board Status
  2. on:
  3. pull_request_target:
  4. types:
  5. - opened
  6. - reopened
  7. - ready_for_review
  8. - edited
  9. - closed
  10. branches:
  11. - develop
  12. permissions: {}
  13. concurrency:
  14. group: project-board-status-${{ github.event.pull_request.number }}
  15. jobs:
  16. project-board-status:
  17. if: github.repository_owner == 'GameServerManagers'
  18. runs-on: ubuntu-latest
  19. steps:
  20. - name: Move linked issues on the project boards
  21. uses: actions/github-script@v9
  22. with:
  23. github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
  24. script: |
  25. const { owner, repo } = context.repo;
  26. const pr = context.payload.pull_request;
  27. const org = 'GameServerManagers';
  28. const boardNumber = 8;
  29. if (context.payload.action === 'closed' && !pr.merged) {
  30. return;
  31. }
  32. const targets = pr.merged ? ['Awaiting Release', 'In Review'] : ['In Progress'];
  33. const movableFrom = pr.merged
  34. ? ['New Issues', 'Sentenced Issues', 'The Fridge', 'On Hold', 'In Progress']
  35. : ['New Issues', 'Sentenced Issues', 'The Fridge'];
  36. const columnOf = (name) => (name || '').replace(/^[^A-Za-z]+/, '').trim();
  37. const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
  38. const closingRef = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+(?:([\w.-]+)\/([\w.-]+))?#(\d+)/gi;
  39. const numbers = new Set();
  40. for (const m of body.matchAll(closingRef)) {
  41. if (m[1] && (m[1].toLowerCase() !== owner.toLowerCase() || m[2].toLowerCase() !== repo.toLowerCase())) {
  42. continue;
  43. }
  44. numbers.add(Number(m[3]));
  45. }
  46. if (numbers.size === 0) {
  47. core.info('No closing issue references in the PR body.');
  48. return;
  49. }
  50. const projectCache = new Map();
  51. async function project(number) {
  52. if (!projectCache.has(number)) {
  53. const data = await github.graphql(
  54. `query($org:String!,$number:Int!){
  55. organization(login:$org){
  56. projectV2(number:$number){
  57. id title
  58. field(name:"Status"){ ... on ProjectV2SingleSelectField { id options { id name } } }
  59. }
  60. }
  61. }`,
  62. { org, number }
  63. );
  64. projectCache.set(number, data.organization.projectV2);
  65. }
  66. return projectCache.get(number);
  67. }
  68. async function setStatus(proj, itemId) {
  69. const option = targets.map((t) => proj.field.options.find((o) => columnOf(o.name) === t)).find(Boolean);
  70. if (!option) {
  71. throw new Error(`${proj.title} has no "${targets.join('" or "')}" column`);
  72. }
  73. await github.graphql(
  74. `mutation($project:ID!,$item:ID!,$field:ID!,$option:String!){
  75. updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{singleSelectOptionId:$option}}){ projectV2Item { id } }
  76. }`,
  77. { project: proj.id, item: itemId, field: proj.field.id, option: option.id }
  78. );
  79. return columnOf(option.name);
  80. }
  81. for (const number of numbers) {
  82. try {
  83. const data = await github.graphql(
  84. `query($owner:String!,$repo:String!,$number:Int!){
  85. repository(owner:$owner,name:$repo){
  86. issueOrPullRequest(number:$number){
  87. __typename
  88. ... on Issue {
  89. id state
  90. projectItems(first:20){
  91. nodes {
  92. id
  93. project { number owner { ... on Organization { login } } }
  94. fieldValueByName(name:"Status"){ ... on ProjectV2ItemFieldSingleSelectValue { name } }
  95. }
  96. }
  97. }
  98. }
  99. }
  100. }`,
  101. { owner, repo, number }
  102. );
  103. const issue = data.repository.issueOrPullRequest;
  104. if (!issue || issue.__typename !== 'Issue' || issue.state !== 'OPEN') {
  105. continue;
  106. }
  107. let items = issue.projectItems.nodes.filter((i) => i.project.owner?.login === org && i.project.number === boardNumber);
  108. if (items.length === 0) {
  109. const proj = await project(boardNumber);
  110. const added = await github.graphql(
  111. `mutation($project:ID!,$content:ID!){ addProjectV2ItemById(input:{projectId:$project,contentId:$content}){ item { id } } }`,
  112. { project: proj.id, content: issue.id }
  113. );
  114. items = [{ id: added.addProjectV2ItemById.item.id, project: { number: boardNumber }, fieldValueByName: null }];
  115. core.info(`Added #${number} to ${proj.title}.`);
  116. }
  117. for (const item of items) {
  118. const proj = await project(item.project.number);
  119. const current = columnOf(item.fieldValueByName?.name);
  120. if (current && !movableFrom.includes(current)) {
  121. core.info(`#${number} stays in ${current} on ${proj.title}.`);
  122. continue;
  123. }
  124. const moved = await setStatus(proj, item.id);
  125. core.info(`Moved #${number} from ${current || 'no status'} to ${moved} on ${proj.title}.`);
  126. }
  127. } catch (error) {
  128. core.warning(`#${number}: ${error.message}`);
  129. }
  130. }