diff --git a/.github/workflows/.github/workflows/label-to-release-announcement.yml b/.github/workflows/.github/workflows/label-to-release-announcement.yml new file mode 100644 index 0000000000..013b36d60c --- /dev/null +++ b/.github/workflows/.github/workflows/label-to-release-announcement.yml @@ -0,0 +1,163 @@ +name: Create a release discussions for each new version label + +on: + schedule: + - cron: "0 * * * *" # every hour + workflow_dispatch: # allow manual runs +permissions: + contents: read + discussions: write + issues: read + pull-requests: read + +jobs: + reconcile: + runs-on: ubuntu-latest + steps: + - name: Reconcile release/* labels → discussions + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const categoryName = "Releases"; + + // 24h cutoff + const since = new Date(Date.now() - 24*60*60*1000).toISOString(); + core.info(`Scanning issues/PRs updated since ${since}`); + + // fetch repo + discussion categories + const repoData = await github.graphql(` + query($owner:String!, $repo:String!){ + repository(owner:$owner, name:$repo){ + id + discussionCategories(first:100){ nodes { id name } } + } + } + `, { owner, repo }); + const repoId = repoData.repository.id; + const category = repoData.repository.discussionCategories.nodes.find(c => c.name === categoryName); + if (!category) { + core.setFailed(`Discussion category "${categoryName}" not found`); + return; + } + const categoryId = category.id; + + // paginate issues/PRs updated in last 24h + for await (const { data: items } of github.paginate.iterator( + github.rest.issues.listForRepo, + { owner, repo, state: "all", since, per_page: 100 } + )) { + for (const item of items) { + const releaseLabels = (item.labels || []) + .map(l => (typeof l === "string" ? l : l.name)) // always get the name + .filter(n => typeof n === "string" && n.startsWith("release/")); + if (releaseLabels.length === 0) continue; + + core.info(`#${item.number}: ${releaseLabels.join(", ")}`); + + for (const labelName of releaseLabels) { + const version = labelName.substring("release/".length); + const titleTarget = `Release: ${version}`; + + // search discussions + let discussionId = null; + let cursor = null; + while (true) { + const page = await github.graphql(` + query($owner:String!, $repo:String!, $cursor:String){ + repository(owner:$owner, name:$repo){ + discussions(first:50, after:$cursor){ + nodes{ + id + title + url + category{ name } + labels(first:50){ nodes{ name } } + } + pageInfo{ hasNextPage endCursor } + } + } + } + `, { owner, repo, cursor }); + const nodes = page.repository.discussions.nodes; + const byLabel = nodes.find(d => + d.category?.name === categoryName && + d.labels?.nodes?.some(l => l.name === labelName) + ); + if (byLabel) { discussionId = byLabel.id; break; } + const byTitle = nodes.find(d => + d.category?.name === categoryName && + d.title === titleTarget + ); + if (byTitle) { discussionId = byTitle.id; break; } + if (!page.repository.discussions.pageInfo.hasNextPage) break; + cursor = page.repository.discussions.pageInfo.endCursor; + } + + if (!discussionId) { + core.info(`→ Creating discussion for ${labelName}`); + const body = + `**Release date:** TODO (YYYY-MM-DD)\n\n` + + `### Links\n` + + `- [Issues and pull requests marked for version ${version}](https://github.com/${owner}/${repo}/issues?q=label%3A${encodeURIComponent(labelName)})\n`; + + const created = await github.graphql(` + mutation($repoId:ID!, $catId:ID!, $title:String!, $body:String!){ + createDiscussion(input:{ + repositoryId:$repoId, + categoryId:$catId, + title:$title, + body:$body + }){ discussion{ id url } } + } + `, { repoId, catId: categoryId, title: titleTarget, body }); + + discussionId = created.createDiscussion.discussion.id; + + // lock the discussion to prevent replies + await github.graphql(` + mutation($id:ID!){ + lockLockable(input:{ lockableId:$id }) { + clientMutationId + } + } + `, { id: discussionId }); + core.info(`🔒 Locked discussion ${discussionId}`); + } else { + core.info(`→ Found existing discussion for ${labelName}`); + } + + // ensure label exists + let labelId; + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status === 404) { + await github.rest.issues.createLabel({ + owner, repo, name: labelName, color: "0E8A16" + }); + } else { throw e; } + } + const labelNode = await github.graphql(` + query($owner:String!, $repo:String!, $name:String!){ + repository(owner:$owner, name:$repo){ label(name:$name){ id } } + } + `, { owner, repo, name: labelName }); + labelId = labelNode.repository.label?.id; + if (!labelId) continue; + + // add label to discussion + await github.graphql(` + mutation($id:ID!, $labels:[ID!]!){ + addLabelsToLabelable(input:{ labelableId:$id, labelIds:$labels }) { + clientMutationId + } + } + `, { id: discussionId, labels: [labelId] }); + + core.info(`✓ ${labelName} attached to discussion`); + } + } + }