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 +7 -0
- data/.github/CODEOWNERS +3 -0
- data/.github/dependabot.yml +7 -0
- data/.github/scripts/README.md +128 -0
- data/.github/scripts/pr-report.js +188 -0
- data/.github/scripts/pr-report.test.js +230 -0
- data/.github/workflows/README.md +7 -0
- data/.github/workflows/ci.yml +75 -0
- data/.github/workflows/pr-report.yml +34 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +16 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +18 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +45 -0
- data/LICENSE +21 -0
- data/README.md +164 -0
- data/Rakefile +12 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/envoy_publisher.gemspec +37 -0
- data/lib/envoy/configuration.rb +27 -0
- data/lib/envoy/publisher.rb +68 -0
- data/lib/envoy/version.rb +5 -0
- data/lib/envoy_publisher.rb +55 -0
- metadata +97 -0
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
|
data/.github/CODEOWNERS
ADDED
|
@@ -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
data/.rubocop.yml
ADDED
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
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,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,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: []
|