diff --git a/.github/workflows/release-notify.yaml b/.github/workflows/release-notify.yaml new file mode 100644 index 000000000..e0499aa7f --- /dev/null +++ b/.github/workflows/release-notify.yaml @@ -0,0 +1,354 @@ +name: Release Notification + +on: + workflow_run: + workflows: ["Publish"] + types: [completed] + +jobs: + notify: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + permissions: + contents: read + issues: write + pull-requests: write + env: + TARGET_OWNER: koxudaxi + TARGET_REPO: datamodel-code-generator + steps: + - name: Post release notifications + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // Get tag from the workflow run that triggered this + let tag = context.payload.workflow_run.head_branch; + + // Fallback: if head_branch is empty, resolve tag from head_sha + if (!tag) { + const headSha = context.payload.workflow_run.head_sha; + console.log(`head_branch is empty, resolving tag from head_sha: ${headSha}`); + + try { + for await (const response of github.paginate.iterator( + github.rest.repos.listTags, + { owner: context.repo.owner, repo: context.repo.repo, per_page: 100 } + )) { + const matchingTag = response.data.find(t => t.commit.sha === headSha); + if (matchingTag) { + tag = matchingTag.name; + console.log(`Resolved tag from sha: ${tag}`); + break; + } + } + } catch (e) { + console.log(`Failed to resolve tag from sha: ${e.message}`); + } + } + + if (!tag) { + console.log('Could not determine tag, skipping notification'); + return; + } + + // Fetch release info for this tag + let release; + try { + const { data } = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: tag + }); + release = data; + } catch (e) { + console.log(`No release found for tag ${tag}: ${e.message}`); + return; + } + + const version = release.tag_name; + const releaseUrl = release.html_url; + const releaseBody = release.body || ''; + + // Target repo from environment variables (set in jobs.notify.env) + const repoOwner = process.env.TARGET_OWNER; + const repoName = process.env.TARGET_REPO; + + // Validate environment variables + if (!repoOwner || !repoName) { + throw new Error('TARGET_OWNER and TARGET_REPO are required'); + } + + // Verify we're running in the correct repo + if (context.repo.owner !== repoOwner || context.repo.repo !== repoName) { + console.log(`Skipping: running in ${context.repo.owner}/${context.repo.repo}, not ${repoOwner}/${repoName}`); + return; + } + + // Helper: Get all comments with pagination + async function getAllComments(issueNumber) { + const comments = []; + for await (const response of github.paginate.iterator( + github.rest.issues.listComments, + { owner: repoOwner, repo: repoName, issue_number: issueNumber, per_page: 100 } + )) { + comments.push(...response.data); + } + return comments; + } + + // Extract PR numbers from release notes (this repo only, PRs only) + const prNumbers = new Set(); + + // Pattern 1: Full PR URL (this repo only) + const prUrlRegex = new RegExp( + 'https://github\\.com/' + repoOwner + '/' + repoName + '/pull/(\\d+)', 'gi' + ); + let m; + while ((m = prUrlRegex.exec(releaseBody)) !== null) { + prNumbers.add(parseInt(m[1])); + } + + // Pattern 2: owner/repo#123 (this repo only) - verify it's a PR later + const repoRefRegex = new RegExp( + '(?:^|[\\s({\\[-])' + repoOwner + '/' + repoName + '#(\\d+)', 'gi' + ); + while ((m = repoRefRegex.exec(releaseBody)) !== null) { + prNumbers.add(parseInt(m[1])); + } + + // Pattern 3: Standalone #123 - verify it's a PR later + const standaloneRefRegex = /(?:^|[\s({\[-])#(\d+)(?=[\s,.)\]}\]:;]|$)/g; + while ((m = standaloneRefRegex.exec(releaseBody)) !== null) { + prNumbers.add(parseInt(m[1])); + } + + console.log(`Found ${prNumbers.size} potential PRs in release ${version}`); + + // Helper: Extract issue numbers with word boundary + function extractIssueNumbers(text, keywordPattern) { + const issues = new Set(); + let m; + + // Pattern 1: keyword #123 (with word boundary) + const simplePattern = new RegExp( + '(?:^|\\s|[({\\[-])' + keywordPattern + '\\s*[:#]?\\s*#(\\d+)', 'gi' + ); + while ((m = simplePattern.exec(text)) !== null) { + issues.add(parseInt(m[1])); + } + + // Pattern 2: keyword owner/repo#123 (same repo only) + const crossRepoPattern = new RegExp( + '(?:^|\\s|[({\\[-])' + keywordPattern + '\\s*[:#]?\\s*' + + repoOwner + '/' + repoName + '#(\\d+)', 'gi' + ); + while ((m = crossRepoPattern.exec(text)) !== null) { + issues.add(parseInt(m[1])); + } + + // Pattern 3: keyword URL (same repo only) + const urlPattern = new RegExp( + '(?:^|\\s|[({\\[-])' + keywordPattern + '\\s*[:#]?\\s*https://github\\.com/' + + repoOwner + '/' + repoName + '/issues/(\\d+)', 'gi' + ); + while ((m = urlPattern.exec(text)) !== null) { + issues.add(parseInt(m[1])); + } + + return issues; + } + + // Word boundary patterns for keywords + const closingKeywordBase = '(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)'; + const relatedKeywordBase = '(?:related(?:\\s+to)?|see|ref(?:s|erences)?)'; + + const processedIssues = new Set(); + const processedPRs = new Set(); + + for (const prNumber of prNumbers) { + try { + // Get PR details (skip if not a PR) + let prBody = ''; + try { + const { data: pr } = await github.rest.pulls.get({ + owner: repoOwner, + repo: repoName, + pull_number: prNumber + }); + prBody = pr.body || ''; + } catch (prError) { + if (prError.status === 404) { + console.log(`#${prNumber} is not a PR (likely an Issue), skipping - only PRs are notified`); + continue; + } + throw prError; + } + + // Post comment to PR + if (!processedPRs.has(prNumber)) { + const prComment = `🎉 **Released in [${version}](${releaseUrl})**\n\nThis PR is now available in the latest release. See the [release notes](${releaseUrl}) for details.`; + + const prComments = await getAllComments(prNumber); + const hasExistingComment = prComments.some(c => + c.body && c.body.includes(`Released in [${version}]`) + ); + + if (!hasExistingComment) { + await github.rest.issues.createComment({ + owner: repoOwner, + repo: repoName, + issue_number: prNumber, + body: prComment + }); + console.log(`Posted release comment to PR #${prNumber}`); + } + processedPRs.add(prNumber); + } + + // Find closing issues (Fixes/Closes/Resolves) + const closingIssues = extractIssueNumbers(prBody, closingKeywordBase); + + // Find related issues (Related/See/Ref) + const relatedIssues = extractIssueNumbers(prBody, relatedKeywordBase); + + // Remove closing issues from related issues to prevent duplicate comments + for (const issueNum of closingIssues) { + relatedIssues.delete(issueNum); + } + + // Find referenced PRs in PR body + const referencedPRs = new Set(); + const prRefUrlPattern = new RegExp( + 'https://github\\.com/' + repoOwner + '/' + repoName + '/pull/(\\d+)', 'gi' + ); + let prRefMatch; + while ((prRefMatch = prRefUrlPattern.exec(prBody)) !== null) { + const refPrNum = parseInt(prRefMatch[1]); + if (refPrNum !== prNumber) { + referencedPRs.add(refPrNum); + } + } + + // Find standalone issue/PR references not in closing/related + const standalonePattern = /(?:^|[\s({\[-])#(\d+)(?=[\s,.)\]}\]:;]|$)/g; + const issueUrlPattern = new RegExp( + 'https://github\\.com/' + repoOwner + '/' + repoName + '/issues/(\\d+)', 'gi' + ); + let standaloneMatch; + while ((standaloneMatch = standalonePattern.exec(prBody)) !== null) { + const refNum = parseInt(standaloneMatch[1]); + if (!closingIssues.has(refNum) && refNum !== prNumber) { + relatedIssues.add(refNum); + } + } + while ((standaloneMatch = issueUrlPattern.exec(prBody)) !== null) { + const issueNum = parseInt(standaloneMatch[1]); + if (!closingIssues.has(issueNum) && !relatedIssues.has(issueNum)) { + relatedIssues.add(issueNum); + } + } + + // Post to closing issues + for (const issueNumber of closingIssues) { + const key = `closing-${issueNumber}`; + if (processedIssues.has(key)) continue; + processedIssues.add(key); + + try { + const issueComments = await getAllComments(issueNumber); + const hasExistingComment = issueComments.some(c => + c.body && c.body.includes(`Released in [${version}]`) + ); + + if (!hasExistingComment) { + const comment = `🎉 **Released in [${version}](${releaseUrl})**\n\nThe fix/feature from PR #${prNumber} has been included in this release. See the [release notes](${releaseUrl}) for details.\n\nThank you for your contribution!`; + await github.rest.issues.createComment({ + owner: repoOwner, + repo: repoName, + issue_number: issueNumber, + body: comment + }); + console.log(`Posted closing comment to issue #${issueNumber}`); + } + } catch (e) { + console.log(`Failed to comment on issue #${issueNumber}: ${e.message}`); + } + } + + // Post to related issues (not closing) + for (const issueNumber of relatedIssues) { + const key = `related-${issueNumber}`; + if (processedIssues.has(key)) continue; + processedIssues.add(key); + + try { + // Check if it's actually an issue (not a PR) + try { + await github.rest.pulls.get({ + owner: repoOwner, + repo: repoName, + pull_number: issueNumber + }); + // It's a PR, add to referencedPRs instead + referencedPRs.add(issueNumber); + continue; + } catch (checkError) { + if (checkError.status !== 404) throw checkError; + // It's an issue, continue + } + + const issueComments = await getAllComments(issueNumber); + const hasExistingComment = issueComments.some(c => + c.body && (c.body.includes(`Released in [${version}]`) || + c.body.includes(`Related PR Released`)) + ); + + if (!hasExistingComment) { + const comment = `📢 **Related PR Released: [${version}](${releaseUrl})**\n\nPR #${prNumber}, which references this issue, has been released. See the [release notes](${releaseUrl}) for details.\n\nNote: This issue was not explicitly closed by the PR.`; + await github.rest.issues.createComment({ + owner: repoOwner, + repo: repoName, + issue_number: issueNumber, + body: comment + }); + console.log(`Posted related comment to issue #${issueNumber}`); + } + } catch (e) { + console.log(`Failed to comment on issue #${issueNumber}: ${e.message}`); + } + } + + // Post to referenced PRs + for (const refPrNumber of referencedPRs) { + const key = `refpr-${refPrNumber}`; + if (processedPRs.has(refPrNumber) || processedIssues.has(key)) continue; + processedIssues.add(key); + + try { + const refPrComments = await getAllComments(refPrNumber); + const hasExistingComment = refPrComments.some(c => + c.body && c.body.includes(`Released in [${version}]`) + ); + + if (!hasExistingComment) { + const comment = `📢 **Related PR Released: [${version}](${releaseUrl})**\n\nPR #${prNumber}, which references this PR, has been released. See the [release notes](${releaseUrl}) for details.`; + await github.rest.issues.createComment({ + owner: repoOwner, + repo: repoName, + issue_number: refPrNumber, + body: comment + }); + console.log(`Posted related comment to PR #${refPrNumber}`); + } + } catch (e) { + console.log(`Failed to comment on PR #${refPrNumber}: ${e.message}`); + } + } + + } catch (e) { + console.log(`Failed to process PR #${prNumber}: ${e.message}`); + } + } + + console.log(`Release notification complete for ${version}`);