Create a release discussions for each new version label #907
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: 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/") && n !== "release/no-notes"); | |
| 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`); | |
| } | |
| } | |
| } |