envoy_publisher 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4071a859fbc8ce8899ee0ab98c014b3f097641f1088a2fd53e6cdcfbe3cfbdee
4
+ data.tar.gz: 63ad7bcdb207da1732af6c9ad23586461b0a721a35cad8ffef323032f412f33c
5
+ SHA512:
6
+ metadata.gz: 0321c157c4150cba0d12df28d504a5ad1cca7e00ac7a57de7461ab211a40163515569a9c255b0dba4c8fc7568eb60abb281de209703ae1417d7c703731728a62
7
+ data.tar.gz: 14dee978e725fb38afe9fa060a96b65375b9ea922625cebfa500751a1efb27969f0d03e999bc14497ea4738f5c338e80a3c3a8dbce1a2ceedb49ba5f5d5aa009
@@ -0,0 +1,3 @@
1
+ # Default CODEOWNERS - 2 User Policy
2
+ * @rtulin841_comcast
3
+ * @cadams959_comcast
@@ -0,0 +1,7 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: "/"
5
+ schedule:
6
+ interval: daily
7
+ open-pull-requests-limit: 10
@@ -0,0 +1,128 @@
1
+ # GitHub Actions Scripts
2
+
3
+ This directory contains JavaScript scripts used by GitHub Actions workflows.
4
+
5
+ ## Scripts
6
+
7
+ ### pr-report.js
8
+
9
+ Generates a Slack-formatted report of open pull requests, including:
10
+ - Check run status (passing, failing, in progress)
11
+ - Approval status
12
+ - PR age with color-coded indicators
13
+
14
+ **Used by:** `.github/workflows/pr-report.yml`
15
+
16
+ ## Local Testing
17
+
18
+ ### Prerequisites
19
+
20
+ - Node.js (v12 or higher)
21
+ - For live testing: A GitHub personal access token with `repo` scope
22
+
23
+ ### Running Mock Tests
24
+
25
+ Run tests with mock data (no GitHub API calls):
26
+
27
+ ```bash
28
+ node .github/scripts/pr-report.test.js
29
+ ```
30
+
31
+ Expected output:
32
+ ```
33
+ ๐Ÿงช Running mock test...
34
+
35
+ Output [has_prs]: true
36
+ Output [blocks]: [{"type":"section",...
37
+
38
+ โœ… Validations:
39
+ โœ“ has_prs is "true"
40
+ โœ“ blocks contains 4 sections
41
+ โœ“ First block is header section
42
+
43
+ ๐Ÿ“‹ Slack Blocks Preview:
44
+ [
45
+ {
46
+ "type": "section",
47
+ "text": {
48
+ "type": "mrkdwn",
49
+ "text": ":clipboard: *Open Pull Requests Report* (3 total)"
50
+ }
51
+ },
52
+ ...
53
+ ]
54
+
55
+ ๐Ÿงช Testing empty PR list...
56
+ Output [has_prs]: false
57
+ โœ“ Empty PR list handled correctly
58
+
59
+ โœ… All tests passed!
60
+ ```
61
+
62
+ ### Running Live Tests
63
+
64
+ Test against a real GitHub repository:
65
+
66
+ ```bash
67
+ GITHUB_TOKEN=ghp_your_token GITHUB_REPO=owner/repo node .github/scripts/pr-report.test.js --live
68
+ ```
69
+
70
+ Example:
71
+ ```bash
72
+ GITHUB_TOKEN=ghp_abc123 GITHUB_REPO=myorg/selfscan node .github/scripts/pr-report.test.js --live
73
+ ```
74
+
75
+ **Note:** The live test requires the `@octokit/rest` package. Install it first if needed:
76
+ ```bash
77
+ npm install @octokit/rest
78
+ ```
79
+
80
+ ## Configuration
81
+
82
+ The `pr-report.js` script has configurable constants at the top of the file:
83
+
84
+ | Constant | Default | Description |
85
+ |----------|---------|-------------|
86
+ | `AGE_THRESHOLDS.WARNING` | 3 days | PRs older than this show yellow indicator |
87
+ | `AGE_THRESHOLDS.CRITICAL` | 7 days | PRs older than this show red indicator |
88
+ | `CONCURRENCY_LIMIT` | 5 | Max parallel GitHub API requests |
89
+
90
+ ## Adding New Scripts
91
+
92
+ When adding new scripts:
93
+
94
+ 1. Create the script in this directory (e.g., `my-script.js`)
95
+ 2. Export a function that accepts `{ github, context, core }`
96
+ 3. Create a test file (e.g., `my-script.test.js`)
97
+ 4. Update this README with documentation
98
+
99
+ ### Script Template
100
+
101
+ ```javascript
102
+ module.exports = async ({ github, context, core }) => {
103
+ const { owner, repo } = context.repo;
104
+
105
+ try {
106
+ // Your logic here
107
+
108
+ core.setOutput('result', 'value');
109
+ } catch (error) {
110
+ core.setFailed(`Script failed: ${error.message}`);
111
+ }
112
+ };
113
+ ```
114
+
115
+ ### Using in Workflow
116
+
117
+ ```yaml
118
+ - name: Checkout repository
119
+ uses: actions/checkout@v4
120
+
121
+ - name: Run script
122
+ uses: actions/github-script@v8
123
+ with:
124
+ script: |
125
+ const script = require('./.github/scripts/my-script.js');
126
+ await script({ github, context, core });
127
+ ```
128
+
@@ -0,0 +1,188 @@
1
+ // Script to gather open PR details and format for Slack notification
2
+
3
+ // Time constants
4
+ const MS_PER_HOUR = 60 * 60 * 1000;
5
+ const MS_PER_DAY = 24 * MS_PER_HOUR;
6
+
7
+ // Constants for age thresholds (in days)
8
+ const AGE_THRESHOLDS = {
9
+ WARNING: 3,
10
+ CRITICAL: 7
11
+ };
12
+
13
+ // Slack emoji constants
14
+ const EMOJI = {
15
+ CLIPBOARD: ':clipboard:',
16
+ CHECK: ':white_check_mark:',
17
+ WARNING: ':warning:',
18
+ FAIL: ':x:',
19
+ HOURGLASS: ':hourglass:',
20
+ UNKNOWN: ':grey_question:',
21
+ GREEN: ':green_circle:',
22
+ YELLOW: ':yellow_circle:',
23
+ RED: ':red_circle:'
24
+ };
25
+
26
+ /**
27
+ * Calculate human-readable age string and emoji based on thresholds
28
+ */
29
+ function calculateAge(createdAt, now) {
30
+ const ageMs = now - new Date(createdAt);
31
+ const ageDays = Math.floor(ageMs / MS_PER_DAY);
32
+ const ageHours = Math.floor((ageMs % MS_PER_DAY) / MS_PER_HOUR);
33
+
34
+ const parts = [];
35
+ if (ageDays > 0) {
36
+ parts.push(`${ageDays} day${ageDays !== 1 ? 's' : ''}`);
37
+ }
38
+ if (ageHours > 0 || ageDays === 0) {
39
+ parts.push(`${ageHours} hour${ageHours !== 1 ? 's' : ''}`);
40
+ }
41
+
42
+ let emoji = EMOJI.GREEN;
43
+ if (ageDays >= AGE_THRESHOLDS.CRITICAL) {
44
+ emoji = EMOJI.RED;
45
+ } else if (ageDays >= AGE_THRESHOLDS.WARNING) {
46
+ emoji = EMOJI.YELLOW;
47
+ }
48
+
49
+ return { ageStr: parts.join(', '), ageEmoji: emoji, ageDays };
50
+ }
51
+
52
+ /**
53
+ * Determine check runs status from GitHub checks API response
54
+ */
55
+ function getChecksStatus(checkRuns) {
56
+ if (checkRuns.total_count === 0) {
57
+ return `${EMOJI.UNKNOWN} No checks`;
58
+ }
59
+
60
+ const failedChecks = checkRuns.check_runs.filter(
61
+ check => check.status === 'completed' && check.conclusion === 'failure'
62
+ );
63
+ const pendingChecks = checkRuns.check_runs.filter(
64
+ check => check.status !== 'completed'
65
+ );
66
+
67
+ if (failedChecks.length > 0) {
68
+ return `${EMOJI.FAIL} Failing (${failedChecks.length} failed)`;
69
+ }
70
+ if (pendingChecks.length > 0) {
71
+ return `${EMOJI.HOURGLASS} In Progress`;
72
+ }
73
+ return `${EMOJI.CHECK} Passing`;
74
+ }
75
+
76
+ /**
77
+ * Determine approval status from reviews
78
+ */
79
+ function getApprovalStatus(reviews) {
80
+ const isApproved = reviews.some(review => review.state === 'APPROVED');
81
+ return isApproved ? `${EMOJI.CHECK} Approved` : `${EMOJI.WARNING} Not Approved`;
82
+ }
83
+
84
+ /**
85
+ * Build Slack blocks for the PR report
86
+ */
87
+ function buildSlackBlocks(prDetails) {
88
+ const headerBlock = {
89
+ type: 'section',
90
+ text: {
91
+ type: 'mrkdwn',
92
+ text: `${EMOJI.CLIPBOARD} *Open Pull Requests Report* (${prDetails.length} total)`
93
+ }
94
+ };
95
+
96
+ const prBlocks = prDetails.map(pr => ({
97
+ type: 'section',
98
+ text: {
99
+ type: 'mrkdwn',
100
+ text: [
101
+ `*<${pr.url}|#${pr.number}: ${pr.title}>* (${pr.author})`,
102
+ `โ€ข Status: ${pr.checksStatus}`,
103
+ `โ€ข Approval: ${pr.approvalStatus}`,
104
+ `โ€ข Age: ${pr.ageEmoji} ${pr.age}`
105
+ ].join('\n')
106
+ }
107
+ }));
108
+
109
+ return [headerBlock, ...prBlocks];
110
+ }
111
+
112
+ /**
113
+ * Fetch PR details including reviews and check runs
114
+ */
115
+ async function fetchPRDetails(github, owner, repo, pr, now) {
116
+ // Fetch reviews and check runs in parallel for better performance
117
+ const [reviewsResponse, checkRunsResponse] = await Promise.all([
118
+ github.rest.pulls.listReviews({
119
+ owner,
120
+ repo,
121
+ pull_number: pr.number
122
+ }),
123
+ github.rest.checks.listForRef({
124
+ owner,
125
+ repo,
126
+ ref: pr.head.sha
127
+ })
128
+ ]);
129
+
130
+ const { ageStr, ageEmoji, ageDays } = calculateAge(pr.created_at, now);
131
+
132
+ return {
133
+ number: pr.number,
134
+ title: pr.title,
135
+ author: pr.user.login,
136
+ url: pr.html_url,
137
+ branch: pr.head.ref,
138
+ approvalStatus: getApprovalStatus(reviewsResponse.data),
139
+ checksStatus: getChecksStatus(checkRunsResponse.data),
140
+ age: ageStr,
141
+ ageEmoji,
142
+ ageDays
143
+ };
144
+ }
145
+
146
+ module.exports = async ({ github, context, core }) => {
147
+ const { owner, repo } = context.repo;
148
+
149
+ try {
150
+ const { data: pullRequests } = await github.rest.pulls.list({
151
+ owner,
152
+ repo,
153
+ state: 'open',
154
+ sort: 'created',
155
+ direction: 'asc'
156
+ });
157
+
158
+ if (pullRequests.length === 0) {
159
+ core.setOutput('has_prs', 'false');
160
+ return;
161
+ }
162
+
163
+ const now = new Date();
164
+
165
+ // Fetch all PR details in parallel (with concurrency limit to avoid rate limiting)
166
+ const CONCURRENCY_LIMIT = 5;
167
+ const prDetails = [];
168
+
169
+ for (let i = 0; i < pullRequests.length; i += CONCURRENCY_LIMIT) {
170
+ const batch = pullRequests.slice(i, i + CONCURRENCY_LIMIT);
171
+ const batchResults = await Promise.all(
172
+ batch.map(pr => fetchPRDetails(github, owner, repo, pr, now))
173
+ );
174
+ prDetails.push(...batchResults);
175
+ }
176
+
177
+ // Sort by age (oldest first)
178
+ prDetails.sort((a, b) => b.ageDays - a.ageDays);
179
+
180
+ const blocks = buildSlackBlocks(prDetails);
181
+
182
+ core.setOutput('has_prs', 'true');
183
+ core.setOutput('blocks', JSON.stringify(blocks));
184
+ } catch (error) {
185
+ core.setFailed(`Failed to generate PR report: ${error.message}`);
186
+ }
187
+ };
188
+
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Local test script for pr-report.js
3
+ *
4
+ * Run with: node .github/scripts/pr-report.test.js
5
+ *
6
+ * You can also test against real GitHub API:
7
+ * GITHUB_TOKEN=your_token GITHUB_REPO=owner/repo node .github/scripts/pr-report.test.js --live
8
+ */
9
+
10
+ const prReport = require('./pr-report.js');
11
+
12
+ // Constants
13
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
14
+
15
+ // Mock data for testing
16
+ const mockPullRequests = [
17
+ {
18
+ number: 101,
19
+ title: 'Add new feature',
20
+ user: { login: 'developer1' },
21
+ html_url: 'https://github.com/example/repo/pull/101',
22
+ head: { ref: 'feature-branch', sha: 'abc123' },
23
+ created_at: new Date(Date.now() - 2 * MS_PER_DAY).toISOString() // 2 days ago
24
+ },
25
+ {
26
+ number: 102,
27
+ title: 'Fix critical bug',
28
+ user: { login: 'developer2' },
29
+ html_url: 'https://github.com/example/repo/pull/102',
30
+ head: { ref: 'bugfix-branch', sha: 'def456' },
31
+ created_at: new Date(Date.now() - 5 * MS_PER_DAY).toISOString() // 5 days ago
32
+ },
33
+ {
34
+ number: 103,
35
+ title: 'Old stale PR',
36
+ user: { login: 'developer3' },
37
+ html_url: 'https://github.com/example/repo/pull/103',
38
+ head: { ref: 'stale-branch', sha: 'ghi789' },
39
+ created_at: new Date(Date.now() - 10 * MS_PER_DAY).toISOString() // 10 days ago
40
+ }
41
+ ];
42
+
43
+ const mockReviews = {
44
+ 101: [{ state: 'APPROVED' }],
45
+ 102: [{ state: 'CHANGES_REQUESTED' }],
46
+ 103: []
47
+ };
48
+
49
+ const mockCheckRuns = {
50
+ abc123: {
51
+ total_count: 2,
52
+ check_runs: [
53
+ { status: 'completed', conclusion: 'success' },
54
+ { status: 'completed', conclusion: 'success' }
55
+ ]
56
+ },
57
+ def456: {
58
+ total_count: 2,
59
+ check_runs: [
60
+ { status: 'completed', conclusion: 'success' },
61
+ { status: 'completed', conclusion: 'failure' }
62
+ ]
63
+ },
64
+ ghi789: {
65
+ total_count: 1,
66
+ check_runs: [
67
+ { status: 'in_progress', conclusion: null }
68
+ ]
69
+ }
70
+ };
71
+
72
+ // Create mock GitHub API
73
+ function createMockGitHub() {
74
+ return {
75
+ rest: {
76
+ pulls: {
77
+ list: async () => ({ data: mockPullRequests }),
78
+ listReviews: async ({ pull_number }) => ({
79
+ data: mockReviews[pull_number] || []
80
+ })
81
+ },
82
+ checks: {
83
+ listForRef: async ({ ref }) => ({
84
+ data: mockCheckRuns[ref] || { total_count: 0, check_runs: [] }
85
+ })
86
+ }
87
+ }
88
+ };
89
+ }
90
+
91
+ // Create mock context
92
+ function createMockContext() {
93
+ return {
94
+ repo: {
95
+ owner: 'example',
96
+ repo: 'repo'
97
+ }
98
+ };
99
+ }
100
+
101
+ // Create mock core with output capture
102
+ function createMockCore() {
103
+ const outputs = {};
104
+ return {
105
+ setOutput: (name, value) => {
106
+ outputs[name] = value;
107
+ console.log(`Output [${name}]:`, typeof value === 'string' && value.length > 100
108
+ ? value.substring(0, 100) + '...'
109
+ : value);
110
+ },
111
+ setFailed: (message) => {
112
+ console.error('FAILED:', message);
113
+ process.exitCode = 1;
114
+ },
115
+ getOutputs: () => outputs
116
+ };
117
+ }
118
+
119
+ // Live test using real GitHub API
120
+ async function runLiveTest() {
121
+ const token = process.env.GITHUB_TOKEN;
122
+ const repoFullName = process.env.GITHUB_REPO;
123
+
124
+ if (!token || !repoFullName) {
125
+ console.error('For live testing, set GITHUB_TOKEN and GITHUB_REPO environment variables');
126
+ console.error('Example: GITHUB_TOKEN=ghp_xxx GITHUB_REPO=owner/repo node .github/scripts/pr-report.test.js --live');
127
+ process.exit(1);
128
+ }
129
+
130
+ const [owner, repo] = repoFullName.split('/');
131
+ const { Octokit } = await import('@octokit/rest');
132
+
133
+ const github = {
134
+ rest: new Octokit({ auth: token }).rest
135
+ };
136
+
137
+ const context = { repo: { owner, repo } };
138
+ const core = createMockCore();
139
+
140
+ console.log(`\n๐Ÿ”ด Running LIVE test against ${repoFullName}...\n`);
141
+
142
+ await prReport({ github, context, core });
143
+
144
+ const outputs = core.getOutputs();
145
+ if (outputs.blocks) {
146
+ console.log('\n๐Ÿ“‹ Slack Blocks Preview:');
147
+ console.log(JSON.stringify(JSON.parse(outputs.blocks), null, 2));
148
+ }
149
+ }
150
+
151
+ // Mock test
152
+ async function runMockTest() {
153
+ console.log('๐Ÿงช Running mock test...\n');
154
+
155
+ const github = createMockGitHub();
156
+ const context = createMockContext();
157
+ const core = createMockCore();
158
+
159
+ await prReport({ github, context, core });
160
+
161
+ const outputs = core.getOutputs();
162
+
163
+ // Validate outputs
164
+ console.log('\nโœ… Validations:');
165
+
166
+ if (outputs.has_prs !== 'true') {
167
+ console.error('โŒ Expected has_prs to be "true"');
168
+ process.exitCode = 1;
169
+ } else {
170
+ console.log(' โœ“ has_prs is "true"');
171
+ }
172
+
173
+ if (!outputs.blocks) {
174
+ console.error('โŒ Expected blocks output to be set');
175
+ process.exitCode = 1;
176
+ } else {
177
+ const blocks = JSON.parse(outputs.blocks);
178
+ console.log(` โœ“ blocks contains ${blocks.length} sections`);
179
+
180
+ // Verify structure
181
+ if (!blocks[0] || blocks[0].type !== 'section') {
182
+ console.error('โŒ First block should be a section');
183
+ process.exitCode = 1;
184
+ } else {
185
+ console.log(' โœ“ First block is header section');
186
+ }
187
+
188
+ // Print formatted output
189
+ console.log('\n๐Ÿ“‹ Slack Blocks Preview:');
190
+ console.log(JSON.stringify(blocks, null, 2));
191
+ }
192
+
193
+ // Test empty PR list
194
+ console.log('\n๐Ÿงช Testing empty PR list...');
195
+ const emptyGithub = {
196
+ rest: {
197
+ pulls: {
198
+ list: async () => ({ data: [] })
199
+ }
200
+ }
201
+ };
202
+ const emptyCore = createMockCore();
203
+ await prReport({ github: emptyGithub, context, core: emptyCore });
204
+
205
+ if (emptyCore.getOutputs().has_prs !== 'false') {
206
+ console.error('โŒ Expected has_prs to be "false" for empty list');
207
+ process.exitCode = 1;
208
+ } else {
209
+ console.log(' โœ“ Empty PR list handled correctly');
210
+ }
211
+ }
212
+
213
+ // Main
214
+ (async () => {
215
+ try {
216
+ if (process.argv.includes('--live')) {
217
+ await runLiveTest();
218
+ } else {
219
+ await runMockTest();
220
+ }
221
+
222
+ if (!process.exitCode) {
223
+ console.log('\nโœ… All tests passed!');
224
+ }
225
+ } catch (error) {
226
+ console.error('\nโŒ Test failed with error:', error);
227
+ process.exitCode = 1;
228
+ }
229
+ })();
230
+
@@ -0,0 +1,7 @@
1
+ # GitHub Actions README
2
+
3
+ This repository uses GitHub Actions for continuous integration. The workflows are defined in the `.github/workflows/` directory.
4
+
5
+ ## Available Workflows
6
+ - **CI Pipeline**: Runs tests, linters, and static analysis daily (Mon-Fri at 9am EST) and on every pull request to ensure code quality.
7
+ - **PR Report**: Sends a daily Slack report (Mon-Fri at 9am EST) of open pull requests with status, approval, and age indicators. See [scripts/README.md](../scripts/README.md) for local testing instructions.
@@ -0,0 +1,75 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ schedule:
6
+ - cron: '0 13 * * 1-5' # Run Mondayโ€“Friday at 8:00 AM EST / 9:00 AM EDT
7
+
8
+ env:
9
+ SLACK_CHANNEL: envoy-dev
10
+
11
+ jobs:
12
+ static_analysis:
13
+ runs-on: comcast-ubuntu-latest
14
+ steps:
15
+ - name: Checkout code
16
+ uses: actions/checkout@v6
17
+
18
+ - name: Set up Ruby
19
+ uses: ruby/setup-ruby@v1
20
+ with:
21
+ ruby-version: .ruby-version
22
+ bundler-cache: true
23
+
24
+ - name: Install dependencies for static analysis
25
+ run: gem install bundler-audit rubocop --no-document
26
+
27
+ - name: Run bundler-audit to check for vulnerable dependencies
28
+ run: bundle audit update && bundle audit
29
+
30
+ - name: Lint code for consistent style
31
+ run: rubocop -c .rubocop.yml
32
+
33
+ - name: Notify Slack on failure
34
+ if: failure()
35
+ uses: slackapi/slack-github-action@v2.1.1
36
+ with:
37
+ method: chat.postMessage
38
+ token: ${{ secrets.SLACK_BOT_TOKEN }}
39
+ payload: |
40
+ channel: ${{ env.SLACK_CHANNEL }}
41
+ text: |
42
+ :x: CI Static Analysis Failed
43
+ *Repository:* ${{ github.repository }}
44
+ *Branch:* ${{ github.head_ref || github.ref_name }}
45
+ *Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>
46
+
47
+ test:
48
+ runs-on: comcast-ubuntu-latest
49
+
50
+ steps:
51
+ - name: Checkout code
52
+ uses: actions/checkout@v6
53
+
54
+ - name: Set up Ruby
55
+ uses: ruby/setup-ruby@v1
56
+ with:
57
+ ruby-version: .ruby-version
58
+ bundler-cache: true
59
+
60
+ - name: Run tests
61
+ run: bundle exec rake test
62
+
63
+ - name: Notify Slack on failure
64
+ if: failure()
65
+ uses: slackapi/slack-github-action@v2.1.1
66
+ with:
67
+ method: chat.postMessage
68
+ token: ${{ secrets.SLACK_BOT_TOKEN }}
69
+ payload: |
70
+ channel: ${{ env.SLACK_CHANNEL }}
71
+ text: |
72
+ :x: CI Tests Failed
73
+ *Repository:* ${{ github.repository }}
74
+ *Branch:* ${{ github.head_ref || github.ref_name }}
75
+ *Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>
@@ -0,0 +1,34 @@
1
+ name: PR Report
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 13 * * 1-5' # Run Monday-Friday at 9:00 AM EST (2:00 PM UTC)
6
+ workflow_dispatch: # Allow manual trigger
7
+
8
+ env:
9
+ SLACK_CHANNEL: envoy-dev
10
+
11
+ jobs:
12
+ pr_report:
13
+ runs-on: comcast-ubuntu-latest
14
+ steps:
15
+ - name: Checkout repository
16
+ uses: actions/checkout@v4
17
+
18
+ - name: Get open pull requests
19
+ id: get-prs
20
+ uses: actions/github-script@v8
21
+ with:
22
+ script: |
23
+ const script = require('./.github/scripts/pr-report.js');
24
+ await script({ github, context, core });
25
+
26
+ - name: Send Slack notification
27
+ if: steps.get-prs.outputs.has_prs == 'true'
28
+ uses: slackapi/slack-github-action@v2.1.1
29
+ with:
30
+ method: chat.postMessage
31
+ token: ${{ secrets.SLACK_BOT_TOKEN }}
32
+ payload: |
33
+ channel: ${{ env.SLACK_CHANNEL }}
34
+ blocks: ${{ steps.get-prs.outputs.blocks }}
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /vendor/
10
+ Gemfile.lock
data/.rubocop.yml ADDED
@@ -0,0 +1,16 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.4
3
+ SuggestExtensions: false
4
+ Exclude:
5
+ - 'bin/**/*'
6
+ - 'vendor/**/*'
7
+ NewCops: enable
8
+
9
+ Style/Documentation:
10
+ Enabled: false
11
+
12
+ Metrics/BlockLength:
13
+ CountComments: false
14
+ Exclude:
15
+ - 'test/**/*'
16
+
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-02-03
11
+
12
+ ### Added
13
+ - Initial release
14
+ - `Envoy::Publisher` class for publishing events to AWS SNS
15
+ - `Envoy::Configuration` for block-based configuration
16
+ - `Envoy.publish(event_name, attributes)` convenience method
17
+ - Structured event format with `detail-type`, `project`, `environment`, `event_name`, and `timestamp`
18
+ - Optional logger integration
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in envoy_publisher.gemspec
6
+ gemspec
7
+
8
+ group :development, :test do
9
+ gem 'bundler', '>= 1.17'
10
+ gem 'minitest', '~> 5.0'
11
+ gem 'rake', '~> 13.0'
12
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,45 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ envoy_publisher (0.1.0)
5
+ aws-sdk-sns (~> 1.0)
6
+ rexml (~> 3.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ aws-eventstream (1.4.0)
12
+ aws-partitions (1.1212.0)
13
+ aws-sdk-core (3.242.0)
14
+ aws-eventstream (~> 1, >= 1.3.0)
15
+ aws-partitions (~> 1, >= 1.992.0)
16
+ aws-sigv4 (~> 1.9)
17
+ base64
18
+ bigdecimal
19
+ jmespath (~> 1, >= 1.6.1)
20
+ logger
21
+ aws-sdk-sns (1.112.0)
22
+ aws-sdk-core (~> 3, >= 3.241.4)
23
+ aws-sigv4 (~> 1.5)
24
+ aws-sigv4 (1.12.1)
25
+ aws-eventstream (~> 1, >= 1.0.2)
26
+ base64 (0.3.0)
27
+ bigdecimal (4.0.1)
28
+ jmespath (1.6.2)
29
+ logger (1.7.0)
30
+ minitest (5.27.0)
31
+ rake (13.3.1)
32
+ rexml (3.4.4)
33
+
34
+ PLATFORMS
35
+ ruby
36
+ x86_64-darwin-24
37
+
38
+ DEPENDENCIES
39
+ bundler (>= 1.17)
40
+ envoy_publisher!
41
+ minitest (~> 5.0)
42
+ rake (~> 13.0)
43
+
44
+ BUNDLED WITH
45
+ 2.7.2
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Comcast Cable Communications, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # Envoy
2
+
3
+ A lightweight Ruby gem that provides a simple, configurable wrapper around the AWS SNS SDK for publishing structured application events to SNS topics. Perfect for Rails applications and Ruby Lambda functions.
4
+
5
+ ## Features
6
+
7
+ - Simple configuration with block syntax
8
+ - Structured event payloads with automatic JSON serialization
9
+ - Project and environment context in every event
10
+ - Optional logging integration
11
+
12
+ ## Installation
13
+
14
+ Add this line to your application's Gemfile, pointing to the GitHub repository:
15
+
16
+ ```ruby
17
+ gem 'envoy_publisher', git: 'https://github.com/comcast-security-tools/envoy-publisher.git'
18
+ ```
19
+
20
+ Or specify a branch or tag:
21
+
22
+ ```ruby
23
+ gem 'envoy_publisher', git: 'https://github.com/comcast-security-tools/envoy-publisher.git', branch: 'main'
24
+ gem 'envoy_publisher', git: 'https://github.com/comcast-security-tools/envoy-publisher.git', tag: 'v0.1.0'
25
+ ```
26
+
27
+ Then execute:
28
+
29
+ ```bash
30
+ $ bundle install
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ ### Rails Application
36
+
37
+ Create an initializer at `config/initializers/envoy.rb`:
38
+
39
+ ```ruby
40
+ Envoy.configure do |config|
41
+ # Enable or disable publishing (default: false)
42
+ config.enabled = %w[production staging qa].include?(Rails.env)
43
+
44
+ # Required: The ARN of your SNS topic
45
+ config.sns_arn = ENV.fetch("SNS_TOPIC_ARN")
46
+
47
+ # Optional: AWS region (defaults to AWS_REGION env var or "us-east-1")
48
+ config.aws_region = ENV.fetch("AWS_REGION", "us-east-1")
49
+
50
+ # Optional: Project name included in every event
51
+ config.project = "my-application"
52
+
53
+ # Optional: Environment name included in every event
54
+ config.environment = Rails.env
55
+
56
+ # Optional: Logger for debugging
57
+ config.logger = Rails.logger
58
+ end
59
+ ```
60
+
61
+ ### Ruby Lambda Function
62
+
63
+ In your Lambda handler or bootstrap file:
64
+
65
+ ```ruby
66
+ require 'envoy_publisher'
67
+
68
+ Envoy.configure do |config|
69
+ config.enabled = true
70
+ config.sns_arn = ENV.fetch("SNS_TOPIC_ARN")
71
+ config.aws_region = ENV.fetch("AWS_REGION", "us-east-1")
72
+ config.project = "my-lambda"
73
+ config.environment = ENV.fetch("ENVIRONMENT", "production")
74
+ end
75
+
76
+ def handler(event:, context:)
77
+ Envoy.publish(:order_created, order_id: "12345", customer_id: "67890")
78
+
79
+ { statusCode: 200, body: "Event published" }
80
+ end
81
+ ```
82
+
83
+ ## Usage
84
+
85
+ ### Basic Publishing
86
+
87
+ ```ruby
88
+ # Publish an event directly through the Envoy module
89
+ Envoy.publish(:user_signed_up, user_id: 123, email: "user@example.com")
90
+
91
+ # Or get the publisher instance
92
+ publisher = Envoy.publisher
93
+ publisher.publish(:order_placed, order_id: "12345", total: 99.99)
94
+ ```
95
+
96
+ ### Event Structure
97
+
98
+ Every published event includes a standardized structure:
99
+
100
+ ```ruby
101
+ Envoy.publish(:user_registered, user_id: 123, plan: "premium")
102
+
103
+ # This publishes a message with the following structure:
104
+ # {
105
+ # "detail-type": "Application Event",
106
+ # "project": "my-application",
107
+ # "environment": "production",
108
+ # "event_name": "user_registered",
109
+ # "timestamp": "2026-02-03T12:00:00Z",
110
+ # "user_id": 123,
111
+ # "plan": "premium"
112
+ # }
113
+ ```
114
+
115
+ ### Complex Events
116
+
117
+ Pass any hash attributes to include additional data:
118
+
119
+ ```ruby
120
+ Envoy.publish(:payment_processed,
121
+ order_id: "12345",
122
+ amount: 150.00,
123
+ currency: "USD",
124
+ payment_method: "credit_card",
125
+ metadata: {
126
+ processor: "stripe",
127
+ transaction_id: "txn_abc123"
128
+ }
129
+ )
130
+ ```
131
+
132
+ ## AWS Credentials
133
+
134
+ The gem uses the AWS SDK's default credential chain. Credentials can be provided via:
135
+
136
+ 1. **Environment variables**: `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`
137
+ 2. **IAM role** (recommended for EC2/Lambda/ECS)
138
+ 3. **AWS credentials file**: `~/.aws/credentials`
139
+
140
+ ## Error Handling
141
+
142
+ ```ruby
143
+ begin
144
+ Envoy.publish(:my_event, data: "test")
145
+ rescue Envoy::ConfigurationError => e
146
+ # Configuration is invalid (e.g., missing sns_arn)
147
+ puts "Configuration error: #{e.message}"
148
+ rescue Envoy::PublishError => e
149
+ # Failed to publish to SNS
150
+ puts "Publish error: #{e.message}"
151
+ end
152
+ ```
153
+
154
+ ## Development
155
+
156
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
157
+
158
+ ## Contributing
159
+
160
+ Bug reports and pull requests are welcome on GitHub at https://github.com/comcast-security-tools/envoy-publisher.
161
+
162
+ ## License
163
+
164
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb']
10
+ end
11
+
12
+ task default: :test
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "envoy_publisher"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'envoy/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'envoy_publisher'
9
+ spec.version = Envoy::VERSION
10
+ spec.authors = ['rtulin841']
11
+ spec.email = ['ryan_tulino@comcast.com']
12
+
13
+ spec.summary = 'A Ruby wrapper around AWS SNS SDK for publishing structured application events'
14
+ spec.description = 'Envoy provides a simple, configurable interface for publishing application events to ' \
15
+ 'AWS SNS topics with project and environment context.'
16
+ spec.homepage = 'https://github.com/comcast-security-tools/envoy-publisher'
17
+ spec.license = 'MIT'
18
+
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/comcast-security-tools/envoy-publisher'
21
+ spec.metadata['changelog_uri'] = 'https://github.com/comcast-security-tools/envoy-publisher/blob/main/CHANGELOG.md'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
28
+ end
29
+ spec.bindir = 'exe'
30
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ spec.required_ruby_version = '>= 3.4'
34
+
35
+ spec.add_dependency 'aws-sdk-sns', '~> 1.0'
36
+ spec.add_dependency 'rexml', '~> 3.0'
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envoy
4
+ class Configuration
5
+ attr_accessor :sns_arn,
6
+ :aws_region,
7
+ :project,
8
+ :environment,
9
+ :enabled,
10
+ :logger
11
+
12
+ def initialize
13
+ @enabled = false
14
+ @sns_arn = nil
15
+ @aws_region = ENV.fetch('AWS_REGION', 'us-east-1')
16
+ @project = nil
17
+ @environment = nil
18
+ @logger = nil
19
+ end
20
+
21
+ def validate!
22
+ raise ConfigurationError, 'sns_arn is required' if sns_arn.nil? || sns_arn.empty?
23
+ end
24
+ end
25
+
26
+ class ConfigurationError < Error; end
27
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-sns'
4
+ require 'json'
5
+
6
+ module Envoy
7
+ class Publisher
8
+ attr_reader :config
9
+
10
+ def initialize
11
+ @config = Envoy.configuration
12
+ @config.validate!
13
+ end
14
+
15
+ # Publish an event to the configured SNS topic
16
+ #
17
+ # @param event_name [Symbol, String] The name of the event
18
+ # @param attributes [Hash] Additional attributes to include in the event
19
+ # @return [Aws::SNS::Types::PublishResponse]
20
+ def publish(event_name, attributes = {})
21
+ return unless config.enabled
22
+
23
+ payload = build_event(event_name, attributes).to_json
24
+ log(:info, "Publishing message to SNS topic: #{config.sns_arn}")
25
+ log(:debug, "Payload: #{payload}")
26
+
27
+ publish_to_sns(payload)
28
+ rescue Aws::SNS::Errors::ServiceError => e
29
+ handle_publish_error(e)
30
+ end
31
+
32
+ private
33
+
34
+ def publish_to_sns(payload)
35
+ response = sns_client.publish(topic_arn: config.sns_arn, message: payload)
36
+ log(:info, "Message published successfully. MessageId: #{response.message_id}")
37
+ response
38
+ end
39
+
40
+ def handle_publish_error(error)
41
+ log(:error, "Failed to publish message: #{error.message}")
42
+ raise PublishError, "Failed to publish to SNS: #{error.message}"
43
+ end
44
+
45
+ def sns_client
46
+ @sns_client ||= Aws::SNS::Client.new(region: config.aws_region)
47
+ end
48
+
49
+ def build_event(event_name, attributes)
50
+ {
51
+ 'detail-type': 'Application Event',
52
+ project: config.project,
53
+ environment: config.environment,
54
+ event_name: event_name,
55
+ timestamp: Time.now.utc.iso8601,
56
+ **attributes
57
+ }
58
+ end
59
+
60
+ def log(level, message)
61
+ return unless config.logger
62
+
63
+ config.logger.send(level, "[Envoy] #{message}")
64
+ end
65
+ end
66
+
67
+ class PublishError < Error; end
68
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envoy
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envoy
4
+ class Error < StandardError; end
5
+ end
6
+
7
+ require 'envoy/version'
8
+ require 'envoy/configuration'
9
+ require 'envoy/publisher'
10
+
11
+ module Envoy
12
+ class << self
13
+ # Access the current configuration
14
+ #
15
+ # @return [Configuration]
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ # Configure Envoy with a block
21
+ #
22
+ # @example
23
+ # Envoy.configure do |config|
24
+ # config.sns_arn = "arn:aws:sns:us-east-1:123456789:my-topic"
25
+ # config.project = "my-app"
26
+ # end
27
+ #
28
+ # @yield [Configuration]
29
+ def configure
30
+ yield(configuration)
31
+ end
32
+
33
+ # Access the Publisher instance
34
+ #
35
+ # @return [Publisher]
36
+ def publisher
37
+ @publisher ||= Publisher.new
38
+ end
39
+
40
+ # Convenience method to publish directly
41
+ #
42
+ # @param event_name [Symbol, String] The name of the event
43
+ # @param attributes [Hash] Additional attributes to include in the event
44
+ # @return [Aws::SNS::Types::PublishResponse]
45
+ def publish(event_name, attributes = {})
46
+ publisher.publish(event_name, attributes)
47
+ end
48
+
49
+ # Reset configuration and publisher (useful for testing)
50
+ def reset!
51
+ @configuration = nil
52
+ @publisher = nil
53
+ end
54
+ end
55
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: envoy_publisher
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - rtulin841
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: aws-sdk-sns
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rexml
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ description: Envoy provides a simple, configurable interface for publishing application
41
+ events to AWS SNS topics with project and environment context.
42
+ email:
43
+ - ryan_tulino@comcast.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".github/CODEOWNERS"
49
+ - ".github/dependabot.yml"
50
+ - ".github/scripts/README.md"
51
+ - ".github/scripts/pr-report.js"
52
+ - ".github/scripts/pr-report.test.js"
53
+ - ".github/workflows/README.md"
54
+ - ".github/workflows/ci.yml"
55
+ - ".github/workflows/pr-report.yml"
56
+ - ".gitignore"
57
+ - ".rubocop.yml"
58
+ - ".ruby-version"
59
+ - CHANGELOG.md
60
+ - Gemfile
61
+ - Gemfile.lock
62
+ - LICENSE
63
+ - README.md
64
+ - Rakefile
65
+ - bin/console
66
+ - bin/setup
67
+ - envoy_publisher.gemspec
68
+ - lib/envoy/configuration.rb
69
+ - lib/envoy/publisher.rb
70
+ - lib/envoy/version.rb
71
+ - lib/envoy_publisher.rb
72
+ homepage: https://github.com/comcast-security-tools/envoy-publisher
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://github.com/comcast-security-tools/envoy-publisher
77
+ source_code_uri: https://github.com/comcast-security-tools/envoy-publisher
78
+ changelog_uri: https://github.com/comcast-security-tools/envoy-publisher/blob/main/CHANGELOG.md
79
+ rubygems_mfa_required: 'true'
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '3.4'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.7.2
95
+ specification_version: 4
96
+ summary: A Ruby wrapper around AWS SNS SDK for publishing structured application events
97
+ test_files: []