Feat: LCFS - Investigate GitHub App/Bot for Workflow Notifications #2127 #8
Workflow file for this run
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: Teams Notification | |
| on: | |
| pull_request: | |
| types: [closed, reopened, review_requested, synchronize] | |
| pull_request_review: | |
| types: [submitted] | |
| push: | |
| branches: | |
| - "*" | |
| workflow_run: | |
| workflows: ["Testing pipeline", "Feat: LCFS - Investigate GitHub App/Bot for Workflow Notifications #2127"] | |
| types: [completed] | |
| permissions: | |
| # Required for workflow_run events to access other workflow details | |
| actions: read | |
| contents: read | |
| pull-requests: read | |
| jobs: | |
| notify: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Microsoft Teams Notification | |
| uses: actions/github-script@v6 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const event = context.eventName; | |
| const payload = context.payload; | |
| // Prepare variables based on event type | |
| let message = ""; | |
| let color = ""; | |
| let prNumber = ""; | |
| let prTitle = ""; | |
| let userName = ""; | |
| let userAvatar = ""; | |
| let facts = []; | |
| let reviewers = ""; | |
| // Function to extract PR info from workflow run | |
| async function getPrInfoFromWorkflowRun(run) { | |
| const octokit = github.rest; | |
| try { | |
| // Find the PR associated with this workflow run | |
| const response = await octokit.repos.listPullRequestsAssociatedWithCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| commit_sha: run.head_sha | |
| }); | |
| if (response.data.length > 0) { | |
| const pr = response.data[0]; | |
| // Get PR comments if available | |
| const commentsResponse = await octokit.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number | |
| }); | |
| return { | |
| number: pr.number, | |
| title: pr.title, | |
| user: { | |
| login: pr.user.login, | |
| avatar_url: pr.user.avatar_url | |
| }, | |
| comments: commentsResponse.data | |
| }; | |
| } | |
| return null; | |
| } catch (error) { | |
| console.log(`Error finding PR for workflow: ${error}`); | |
| return null; | |
| } | |
| } | |
| // Function to find PR associated with a push event | |
| async function getPrInfoFromPush(ref) { | |
| const octokit = github.rest; | |
| try { | |
| // Get the branch name from the ref | |
| const branch = ref.replace('refs/heads/', ''); | |
| // Find PRs associated with this branch | |
| const response = await octokit.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| head: `${context.repo.owner}:${branch}` | |
| }); | |
| if (response.data.length > 0) { | |
| const pr = response.data[0]; | |
| return { | |
| number: pr.number, | |
| title: pr.title, | |
| html_url: pr.html_url, | |
| user: { | |
| login: pr.user.login, | |
| avatar_url: pr.user.avatar_url | |
| } | |
| }; | |
| } | |
| return null; | |
| } catch (error) { | |
| console.log(`Error finding PR for push: ${error}`); | |
| return null; | |
| } | |
| } | |
| // Set thread identifier based on PR number to group messages | |
| let threadId = ""; | |
| if (event === 'pull_request') { | |
| const pr = payload.pull_request; | |
| prNumber = pr.number; | |
| prTitle = pr.title; | |
| userName = pr.user.login; | |
| userAvatar = pr.user.avatar_url; | |
| threadId = `pr-${prNumber}`; | |
| const action = payload.action; | |
| if (action === 'opened' || action === 'reopened') { | |
| message = `New PR #${prNumber} opened: **${prTitle}**\n[View PR](${pr.html_url})`; | |
| color = "0076D7"; // blue | |
| facts = [ | |
| { "name": "Status", "value": action === 'opened' ? "Newly Opened" : "Reopened" }, | |
| { "name": "Base Branch", "value": pr.base.ref }, | |
| { "name": "Created", "value": new Date(pr.created_at).toLocaleString() } | |
| ]; | |
| } else if (action === 'closed') { | |
| if (pr.merged) { | |
| message = `PR #${prNumber} merged: **${prTitle}**\n[View PR](${pr.html_url})`; | |
| color = "2CBE4E"; // green | |
| facts = [ | |
| { "name": "Status", "value": "Merged" }, | |
| { "name": "Merged by", "value": payload.sender.login }, | |
| { "name": "Merged at", "value": new Date(pr.merged_at).toLocaleString() } | |
| ]; | |
| } else { | |
| message = `PR #${prNumber} closed without merging: **${prTitle}**\n[View PR](${pr.html_url})`; | |
| color = "D73A49"; // red | |
| facts = [ | |
| { "name": "Status", "value": "Closed" }, | |
| { "name": "Closed by", "value": payload.sender.login }, | |
| { "name": "Closed at", "value": new Date(pr.closed_at).toLocaleString() } | |
| ]; | |
| } | |
| } else if (action === 'synchronize') { | |
| // This is triggered when new commits are pushed to the PR | |
| message = `PR #${prNumber} updated with new commits: **${prTitle}**\n[View PR](${pr.html_url})`; | |
| color = "0076D7"; // blue | |
| facts = [ | |
| { "name": "Status", "value": "New Commits" }, | |
| { "name": "Updated by", "value": payload.sender.login }, | |
| { "name": "Updated at", "value": new Date().toLocaleString() } | |
| ]; | |
| } else if (action === 'review_requested') { | |
| reviewers = payload.requested_reviewers.map(reviewer => reviewer.login).join(', '); | |
| message = `Review requested for PR #${prNumber}: **${prTitle}**\n[View PR](${pr.html_url})`; | |
| color = "FBAB19"; // orange | |
| facts = [ | |
| { "name": "Status", "value": "Review Requested" }, | |
| { "name": "Reviewer(s)", "value": reviewers }, | |
| { "name": "Requested by", "value": payload.sender.login } | |
| ]; | |
| } | |
| } else if (event === 'push') { | |
| // Handle push events | |
| const commits = payload.commits; | |
| const ref = payload.ref; | |
| const branch = ref.replace('refs/heads/', ''); | |
| userName = payload.pusher.name; | |
| userAvatar = `https://github.com/${payload.pusher.name}.png`; | |
| // Try to find associated PR | |
| const prInfo = await getPrInfoFromPush(ref); | |
| if (prInfo) { | |
| // Push is associated with a PR | |
| prNumber = prInfo.number; | |
| prTitle = prInfo.title; | |
| threadId = `pr-${prNumber}`; | |
| // Create commit list for message | |
| const commitList = commits.map(commit => | |
| `• ${commit.message.split('\n')[0]} ([${commit.id.substring(0, 7)}](${commit.url}))` | |
| ).join('\n'); | |
| message = `PR #${prNumber} received ${commits.length} new commit${commits.length > 1 ? 's' : ''}: **${prTitle}**\n\n${commitList}\n\n[View PR](${prInfo.html_url})`; | |
| color = "0076D7"; // blue | |
| facts = [ | |
| { "name": "Status", "value": "New Commits" }, | |
| { "name": "Branch", "value": branch }, | |
| { "name": "Pushed by", "value": userName }, | |
| { "name": "Commit count", "value": commits.length.toString() } | |
| ]; | |
| } else { | |
| // Push is not associated with a PR (direct push to branch) | |
| threadId = `branch-${branch}`; | |
| // Create commit list for message | |
| const commitList = commits.map(commit => | |
| `• ${commit.message.split('\n')[0]} ([${commit.id.substring(0, 7)}](${commit.url}))` | |
| ).join('\n'); | |
| message = `Branch **${branch}** received ${commits.length} new commit${commits.length > 1 ? 's' : ''}\n\n${commitList}\n\n[View changes](${payload.compare})`; | |
| color = "0076D7"; // blue | |
| facts = [ | |
| { "name": "Branch", "value": branch }, | |
| { "name": "Pushed by", "value": userName }, | |
| { "name": "Commit count", "value": commits.length.toString() }, | |
| { "name": "Repository", "value": `${context.repo.owner}/${context.repo.repo}` } | |
| ]; | |
| } | |
| } else if (event === 'pull_request_review') { | |
| const review = payload.review; | |
| const pr = payload.pull_request; | |
| prNumber = pr.number; | |
| prTitle = pr.title; | |
| userName = review.user.login; | |
| userAvatar = review.user.avatar_url; | |
| threadId = `pr-${prNumber}`; | |
| let reviewStatus = ""; | |
| if (review.state === 'approved') { | |
| message = `PR #${prNumber} approved: **${prTitle}**\n[View PR](${pr.html_url})`; | |
| color = "2CBE4E"; // green | |
| reviewStatus = "Approved"; | |
| } else if (review.state === 'changes_requested') { | |
| message = `Changes requested on PR #${prNumber}: **${prTitle}**\n[View PR](${pr.html_url})`; | |
| color = "FBAB19"; // orange | |
| reviewStatus = "Changes Requested"; | |
| } else if (review.state === 'commented') { | |
| message = `PR #${prNumber} received comments: **${prTitle}**\n[View PR](${pr.html_url})`; | |
| color = "0076D7"; // blue | |
| reviewStatus = "Commented"; | |
| } | |
| facts = [ | |
| { "name": "Review Status", "value": reviewStatus }, | |
| { "name": "Review Comment", "value": review.body || "(No comment provided)" } | |
| ]; | |
| } else if (event === 'workflow_run') { | |
| const run = payload.workflow_run; | |
| // Get associated PR information | |
| const prInfo = await getPrInfoFromWorkflowRun(run); | |
| if (prInfo) { | |
| prNumber = prInfo.number; | |
| prTitle = prInfo.title; | |
| userName = prInfo.user.login; | |
| userAvatar = prInfo.user.avatar_url; | |
| threadId = `pr-${prNumber}`; | |
| // Base facts for all workflow runs | |
| facts = [ | |
| { "name": "Workflow", "value": run.name }, | |
| { "name": "Branch", "value": run.head_branch }, | |
| { "name": "Triggered by", "value": run.triggering_actor.login } | |
| ]; | |
| if (run.conclusion === 'success') { | |
| message = `All tests passed for PR #${prNumber}: **${prTitle}**\n\n**Backend tests**: Passed ✅\n**Frontend tests**: Passed ✅\n\n[View test details](${run.html_url})`; | |
| color = "2CBE4E"; // green | |
| facts.push({ "name": "Test Status", "value": "All tests passed" }); | |
| } else if (run.conclusion === 'failure') { | |
| // Try to determine which tests failed | |
| message = `Tests failed for PR #${prNumber}: **${prTitle}**\n\nOne or more tests failed in the pipeline.\n\n[View test details](${run.html_url})`; | |
| color = "D73A49"; // red | |
| facts.push({ "name": "Test Status", "value": "One or more tests failed" }); | |
| } else { | |
| message = `Workflow "${run.name}" for PR #${prNumber}: **${prTitle}** completed with status: ${run.conclusion}\n\n[View test details](${run.html_url})`; | |
| color = "FBAB19"; // orange | |
| facts.push({ "name": "Test Status", "value": run.conclusion }); | |
| } | |
| // Add recent PR comments section if any exist | |
| if (prInfo.comments && prInfo.comments.length > 0) { | |
| // Get the last 3 comments to keep it manageable | |
| const recentComments = prInfo.comments | |
| .slice(-3) | |
| .map(comment => { | |
| return { | |
| "name": `${comment.user.login} wrote:`, | |
| "value": `"${comment.body.length > 100 ? comment.body.substring(0, 100) + '...' : comment.body}" [view](${comment.html_url})` | |
| }; | |
| }); | |
| // Add comment facts | |
| facts.push({ "name": "Recent Comments", "value": " " }); | |
| facts = facts.concat(recentComments); | |
| } | |
| } else { | |
| // Fallback if we can't link to a PR | |
| message = `Workflow "${run.name}" on branch ${run.head_branch} completed with status: ${run.conclusion}\n[View details](${run.html_url})`; | |
| color = run.conclusion === 'success' ? "2CBE4E" : (run.conclusion === 'failure' ? "D73A49" : "FBAB19"); | |
| threadId = `workflow-${run.id}`; | |
| userName = run.triggering_actor.login; | |
| userAvatar = run.triggering_actor.avatar_url; | |
| facts = [ | |
| { "name": "Workflow", "value": run.name }, | |
| { "name": "Branch", "value": run.head_branch }, | |
| { "name": "Status", "value": run.conclusion }, | |
| { "name": "Triggered by", "value": run.triggering_actor.login } | |
| ]; | |
| } | |
| } | |
| // Only proceed if we have a message to send | |
| if (message) { | |
| const webhookUrl = process.env.TEAMS_WEBHOOK_URL; | |
| const card = { | |
| "@type": "MessageCard", | |
| "@context": "http://schema.org/extensions", | |
| "themeColor": color, | |
| "summary": `GitHub Notification for ${prNumber ? `PR #${prNumber}` : 'Repository'}`, | |
| "sections": [ | |
| { | |
| "activityTitle": prNumber | |
| ? `GitHub Notification for PR #${prNumber}` | |
| : `GitHub Notification for ${context.repo.owner}/${context.repo.repo}`, | |
| "activitySubtitle": `Repository: ${context.repo.owner}/${context.repo.repo}`, | |
| "activityImage": userAvatar, | |
| "facts": facts, | |
| "text": message, | |
| "markdown": true | |
| } | |
| ], | |
| // Add correlation ID to group messages for the same PR | |
| "replyToId": threadId, | |
| "potentialAction": [ | |
| { | |
| "@type": "OpenUri", | |
| "name": "View on GitHub", | |
| "targets": [ | |
| { | |
| "os": "default", | |
| "uri": event === 'workflow_run' | |
| ? payload.workflow_run.html_url | |
| : event === 'push' | |
| ? payload.compare | |
| : payload.pull_request?.html_url || payload.repository.html_url | |
| } | |
| ] | |
| } | |
| ] | |
| }; | |
| // Send notification to Teams | |
| const https = require('https'); | |
| const url = new URL(webhookUrl); | |
| const options = { | |
| hostname: url.hostname, | |
| path: url.pathname + url.search, | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| } | |
| }; | |
| const req = https.request(options, (res) => { | |
| console.log(`Teams notification status: ${res.statusCode}`); | |
| res.on('data', (chunk) => { | |
| console.log(`Response: ${chunk}`); | |
| }); | |
| }); | |
| req.on('error', (error) => { | |
| console.error(`Error sending Teams notification: ${error}`); | |
| core.setFailed(`Failed to send Teams notification: ${error.message}`); | |
| }); | |
| req.write(JSON.stringify(card)); | |
| req.end(); | |
| } else { | |
| console.log('No notification configured for this event type or action'); | |
| } | |
| env: | |
| TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |