prwatcher 2.0.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.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/bin/watch-pr.js +17 -0
- package/package.json +30 -0
- package/src/cli.js +175 -0
- package/src/config.js +86 -0
- package/src/github.js +122 -0
- package/src/slack.js +45 -0
- package/src/utils.js +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# prwatcher
|
|
2
|
+
|
|
3
|
+
CLI tool that watches GitHub PRs and sends Slack notifications when CI passes, fails, or the PR is ready to merge.
|
|
4
|
+
|
|
5
|
+
**Zero setup for developers** — just run one command. No Slack app creation, no servers, no ngrok.
|
|
6
|
+
|
|
7
|
+
## Roadmap
|
|
8
|
+
|
|
9
|
+
### Phase 1 — CLI Watcher (Done)
|
|
10
|
+
Run from your terminal. Watches PRs and sends Slack notifications while your laptop is on.
|
|
11
|
+
|
|
12
|
+
### Phase 2 — Cloud Watcher + Multi-Channel (Coming Soon)
|
|
13
|
+
- Run without a laptop via GitHub Actions — completely free, auto-stops when PR is merged/closed
|
|
14
|
+
- Multi-channel notifications: Telegram, Discord, Microsoft Teams
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Phase 1: CLI Watcher
|
|
19
|
+
|
|
20
|
+
### Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g prwatcher
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or use directly with npx:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx prwatcher https://github.com/owner/repo/pull/123
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
prwatcher https://github.com/owner/repo/pull/123
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
On first run, you'll be prompted for:
|
|
39
|
+
1. **GitHub token** — [create one here](https://github.com/settings/tokens) with `repo` scope
|
|
40
|
+
2. **Slack webhook URL** — your team's [Incoming Webhook](https://api.slack.com/messaging/webhooks)
|
|
41
|
+
|
|
42
|
+
These are saved to `~/.prwatcher` so you only enter them once.
|
|
43
|
+
|
|
44
|
+
### Options
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
prwatcher <pr-url> [options]
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
--interval <minutes> Polling interval in minutes (default: 1)
|
|
51
|
+
--reset-config Re-enter GitHub token and Slack webhook URL
|
|
52
|
+
-V, --version Output version number
|
|
53
|
+
-h, --help Display help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Examples
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Watch a PR with default 1-minute polling
|
|
60
|
+
prwatcher https://github.com/org/repo/pull/42
|
|
61
|
+
|
|
62
|
+
# Poll every 30 seconds
|
|
63
|
+
prwatcher https://github.com/org/repo/pull/42 --interval 0.5
|
|
64
|
+
|
|
65
|
+
# Poll every 5 minutes
|
|
66
|
+
prwatcher https://github.com/org/repo/pull/42 --interval 5
|
|
67
|
+
|
|
68
|
+
# Re-enter credentials
|
|
69
|
+
prwatcher https://github.com/org/repo/pull/42 --reset-config
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Notifications
|
|
73
|
+
|
|
74
|
+
You'll get Slack messages when:
|
|
75
|
+
|
|
76
|
+
| Event | Message |
|
|
77
|
+
|-------|---------|
|
|
78
|
+
| Required CI checks fail | ⚠️ CI checks failed (lists which ones) |
|
|
79
|
+
| PR needs rebase | 🔄 PR needs rebase |
|
|
80
|
+
| PR is ready to merge | ✅ PR is ready to merge! |
|
|
81
|
+
| PR is merged | 🎉 PR was merged! |
|
|
82
|
+
| PR is closed | ❌ PR was closed without merging |
|
|
83
|
+
|
|
84
|
+
Notifications only fire on **state changes** — no spam.
|
|
85
|
+
|
|
86
|
+
### Smart CI Detection
|
|
87
|
+
|
|
88
|
+
The tool distinguishes between **required** and **optional** CI checks using GitHub's branch protection rules:
|
|
89
|
+
|
|
90
|
+
- If a **required** check fails → you get notified
|
|
91
|
+
- If an **optional** check fails → ignored, PR can still merge
|
|
92
|
+
- If branch protection can't be read (no admin access) → falls back to treating all checks as required
|
|
93
|
+
|
|
94
|
+
### One-Time Team Setup (Slack Webhook)
|
|
95
|
+
|
|
96
|
+
One team member creates the webhook, shares the URL with everyone:
|
|
97
|
+
|
|
98
|
+
1. Go to [Slack API → Apps](https://api.slack.com/apps)
|
|
99
|
+
2. Create an app (or use existing) → **Incoming Webhooks** → Enable
|
|
100
|
+
3. **Add New Webhook to Workspace** → pick your `#pr-notifications` channel
|
|
101
|
+
4. Copy the webhook URL and share with your team
|
|
102
|
+
|
|
103
|
+
Each developer pastes this URL on their first `prwatcher` run.
|
|
104
|
+
|
|
105
|
+
### How It Works
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
prwatcher <url>
|
|
109
|
+
↓
|
|
110
|
+
Poll GitHub API every N seconds
|
|
111
|
+
↓
|
|
112
|
+
Detect state change (CI failed, ready, merged, etc.)
|
|
113
|
+
↓
|
|
114
|
+
POST to Slack webhook
|
|
115
|
+
↓
|
|
116
|
+
PR merged/closed? → exit
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Phase 2 (Coming Soon)
|
|
122
|
+
|
|
123
|
+
### Cloud Watcher
|
|
124
|
+
> Watch PRs without keeping your laptop open.
|
|
125
|
+
|
|
126
|
+
The CLI will trigger a **GitHub Actions workflow** that runs in the cloud on your behalf. It polls the PR on a schedule, sends notifications on state changes, and auto-stops when the PR is merged or closed.
|
|
127
|
+
|
|
128
|
+
- Free (runs within GitHub Actions' 2,000 free minutes/month)
|
|
129
|
+
- No server to manage
|
|
130
|
+
- Works exactly like Phase 1, but in the cloud
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
prwatcher https://github.com/org/repo/pull/42 --cloud
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Multi-Channel Notifications
|
|
137
|
+
> Not just Slack — get notified wherever your team lives.
|
|
138
|
+
|
|
139
|
+
| Channel | Status |
|
|
140
|
+
|---------|--------|
|
|
141
|
+
| Slack | Done |
|
|
142
|
+
| Telegram | Coming soon |
|
|
143
|
+
| Discord | Coming soon |
|
|
144
|
+
| Microsoft Teams | Coming soon |
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
prwatcher https://github.com/org/repo/pull/42
|
|
148
|
+
# → Where do you want notifications?
|
|
149
|
+
# → 1. Slack 2. Telegram 3. Discord 4. Microsoft Teams
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
package/bin/watch-pr.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const { run } = require('../src/cli');
|
|
5
|
+
|
|
6
|
+
program
|
|
7
|
+
.name('prwatcher')
|
|
8
|
+
.description('Watch a GitHub PR and get Slack notifications on state changes')
|
|
9
|
+
.version('2.0.0')
|
|
10
|
+
.argument('<pr-url>', 'GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)')
|
|
11
|
+
.option('--interval <minutes>', 'polling interval in minutes', '1')
|
|
12
|
+
.option('--reset-config', 're-enter GitHub token and Slack webhook URL')
|
|
13
|
+
.action((prUrl, options) => {
|
|
14
|
+
run(prUrl, options);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prwatcher",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "CLI tool that watches GitHub PRs and sends Slack notifications when CI passes, fails, or PR is ready to merge",
|
|
5
|
+
"main": "src/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"prwatcher": "./bin/watch-pr.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node bin/watch-pr.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"github",
|
|
18
|
+
"pr",
|
|
19
|
+
"ci",
|
|
20
|
+
"slack",
|
|
21
|
+
"watch",
|
|
22
|
+
"cli"
|
|
23
|
+
],
|
|
24
|
+
"author": "",
|
|
25
|
+
"license": "ISC",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@octokit/rest": "^22.0.1",
|
|
28
|
+
"commander": "^14.0.3"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const { parsePRUrl, buildPRKey } = require('./utils');
|
|
2
|
+
const { ensureConfig } = require('./config');
|
|
3
|
+
const { createClient, fetchRequiredChecks, fetchPRState } = require('./github');
|
|
4
|
+
const { sendNotification, formatMerged, formatClosed, formatCIFailed, formatRebaseNeeded, formatReady } = require('./slack');
|
|
5
|
+
|
|
6
|
+
async function run(prUrl, options = {}) {
|
|
7
|
+
// 1. Parse PR URL
|
|
8
|
+
const parsed = parsePRUrl(prUrl);
|
|
9
|
+
if (!parsed) {
|
|
10
|
+
console.error(`Invalid GitHub PR URL: ${prUrl}`);
|
|
11
|
+
console.error('Expected format: https://github.com/owner/repo/pull/123');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { owner, repo, prNumber } = parsed;
|
|
16
|
+
const prKey = buildPRKey(owner, repo, prNumber);
|
|
17
|
+
|
|
18
|
+
// 2. Load or prompt for config
|
|
19
|
+
const config = await ensureConfig(options.resetConfig);
|
|
20
|
+
|
|
21
|
+
// 3. Initialize GitHub client
|
|
22
|
+
const octokit = createClient(config.githubToken);
|
|
23
|
+
|
|
24
|
+
// 4. Fetch required checks (once at startup)
|
|
25
|
+
console.log(`\n👀 Watching ${prKey}`);
|
|
26
|
+
console.log(' Fetching branch protection rules...');
|
|
27
|
+
|
|
28
|
+
let requiredCheckNames = null;
|
|
29
|
+
|
|
30
|
+
// We need to know the base branch first — do an initial PR fetch
|
|
31
|
+
try {
|
|
32
|
+
const initialState = await fetchPRState(octokit, { owner, repo, prNumber, requiredCheckNames: null });
|
|
33
|
+
|
|
34
|
+
if (initialState.closed) {
|
|
35
|
+
const status = initialState.merged ? 'already merged' : 'already closed';
|
|
36
|
+
console.log(`\n PR is ${status}. Nothing to watch.`);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
requiredCheckNames = await fetchRequiredChecks(octokit, {
|
|
41
|
+
owner,
|
|
42
|
+
repo,
|
|
43
|
+
branch: initialState.baseBranch
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (requiredCheckNames) {
|
|
47
|
+
console.log(` ✅ Found ${requiredCheckNames.size} required check(s): ${[...requiredCheckNames].join(', ')}`);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(' ℹ Could not fetch branch protection rules. Treating all checks as required.');
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const msg = error.status
|
|
53
|
+
? `GitHub API error ${error.status}: ${error.response?.data?.message || 'Server error'}`
|
|
54
|
+
: error.message.length > 200
|
|
55
|
+
? error.message.slice(0, 200) + '...'
|
|
56
|
+
: error.message;
|
|
57
|
+
console.error(`\n Failed to fetch PR: ${msg}`);
|
|
58
|
+
if (error.status === 401) {
|
|
59
|
+
console.error(' Your GitHub token may be invalid. Run with --reset-config to re-enter it.');
|
|
60
|
+
}
|
|
61
|
+
if (error.status === 404) {
|
|
62
|
+
console.error(' PR not found. Check the URL and ensure your token has access to this repo.');
|
|
63
|
+
}
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 5. State tracking
|
|
68
|
+
let lastState = null;
|
|
69
|
+
const intervalMinutes = parseFloat(options.interval) || 1;
|
|
70
|
+
const intervalMs = intervalMinutes * 60 * 1000;
|
|
71
|
+
|
|
72
|
+
console.log(` Polling every ${intervalMinutes >= 1 ? `${intervalMinutes} minute(s)` : `${intervalMinutes * 60} seconds`}`);
|
|
73
|
+
console.log(' Press Ctrl+C to stop\n');
|
|
74
|
+
|
|
75
|
+
// 6. Poll function
|
|
76
|
+
async function poll() {
|
|
77
|
+
try {
|
|
78
|
+
const state = await fetchPRState(octokit, { owner, repo, prNumber, requiredCheckNames });
|
|
79
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
80
|
+
|
|
81
|
+
// PR merged
|
|
82
|
+
if (state.closed && state.merged) {
|
|
83
|
+
console.log(` 🎉 ${timestamp} — Merged!`);
|
|
84
|
+
await sendNotification(config.slackWebhookUrl, formatMerged(state.htmlUrl, state.title));
|
|
85
|
+
cleanup();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// PR closed
|
|
91
|
+
if (state.closed && !state.merged) {
|
|
92
|
+
console.log(` ❌ ${timestamp} — Closed without merging`);
|
|
93
|
+
await sendNotification(config.slackWebhookUrl, formatClosed(state.htmlUrl, state.title));
|
|
94
|
+
cleanup();
|
|
95
|
+
process.exit(0);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// CI failed (required checks only)
|
|
100
|
+
if (state.checks && state.checks.failedRequired.length > 0) {
|
|
101
|
+
if (lastState !== 'ci_failed') {
|
|
102
|
+
const failedNames = state.checks.failedRequired.map(c => c.name);
|
|
103
|
+
console.log(` ⚠️ ${timestamp} — Required CI checks failed: ${failedNames.join(', ')}`);
|
|
104
|
+
await sendNotification(config.slackWebhookUrl, formatCIFailed(state.htmlUrl, state.title, failedNames));
|
|
105
|
+
lastState = 'ci_failed';
|
|
106
|
+
} else {
|
|
107
|
+
console.log(` ⚠️ ${timestamp} — CI still failing (already notified)`);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Rebase needed
|
|
113
|
+
if (state.mergeableState === 'behind' || state.mergeableState === 'dirty') {
|
|
114
|
+
if (lastState !== 'rebase_needed') {
|
|
115
|
+
console.log(` 🔄 ${timestamp} — Needs rebase (${state.mergeableState})`);
|
|
116
|
+
await sendNotification(config.slackWebhookUrl, formatRebaseNeeded(state.htmlUrl, state.title));
|
|
117
|
+
lastState = 'rebase_needed';
|
|
118
|
+
} else {
|
|
119
|
+
console.log(` 🔄 ${timestamp} — Still needs rebase (already notified)`);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Ready to merge
|
|
125
|
+
if (state.mergeableState === 'clean') {
|
|
126
|
+
if (lastState !== 'ready') {
|
|
127
|
+
console.log(` ✅ ${timestamp} — Ready to merge!`);
|
|
128
|
+
await sendNotification(config.slackWebhookUrl, formatReady(state.htmlUrl, state.title));
|
|
129
|
+
lastState = 'ready';
|
|
130
|
+
} else {
|
|
131
|
+
console.log(` ✅ ${timestamp} — Still ready (already notified)`);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Pending / other state
|
|
137
|
+
const pending = state.checks ? state.checks.pendingRequired.length : 0;
|
|
138
|
+
const total = state.checks ? state.checks.requiredTotal : 0;
|
|
139
|
+
console.log(` ⏳ ${timestamp} — CI running (${total - pending}/${total} required checks done, mergeable: ${state.mergeableState || 'unknown'})`);
|
|
140
|
+
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
143
|
+
const msg = error.status
|
|
144
|
+
? `GitHub API error ${error.status}: ${error.response?.data?.message || 'Server error (retrying next poll)'}`
|
|
145
|
+
: error.message.length > 200
|
|
146
|
+
? error.message.slice(0, 200) + '...'
|
|
147
|
+
: error.message;
|
|
148
|
+
console.error(` ⚠ ${timestamp} — ${msg}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 7. Graceful shutdown
|
|
153
|
+
let intervalId;
|
|
154
|
+
|
|
155
|
+
function cleanup() {
|
|
156
|
+
if (intervalId) clearInterval(intervalId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
process.on('SIGINT', () => {
|
|
160
|
+
console.log('\n\n Stopped watching. Bye!');
|
|
161
|
+
cleanup();
|
|
162
|
+
process.exit(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
process.on('SIGTERM', () => {
|
|
166
|
+
cleanup();
|
|
167
|
+
process.exit(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// 8. Run immediately, then on interval
|
|
171
|
+
await poll();
|
|
172
|
+
intervalId = setInterval(poll, intervalMs);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { run };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
|
|
6
|
+
const CONFIG_PATH = path.join(os.homedir(), '.watch-pr');
|
|
7
|
+
|
|
8
|
+
function loadConfig() {
|
|
9
|
+
try {
|
|
10
|
+
const data = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
11
|
+
return JSON.parse(data);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function saveConfig(config) {
|
|
18
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function prompt(rl, question) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
rl.question(question, (answer) => {
|
|
24
|
+
resolve(answer.trim());
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function promptForConfig(existing = {}) {
|
|
30
|
+
const rl = readline.createInterface({
|
|
31
|
+
input: process.stdin,
|
|
32
|
+
output: process.stderr,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const config = { ...existing };
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (!config.githubToken) {
|
|
39
|
+
console.error('\n⚠ GitHub token not found.');
|
|
40
|
+
console.error(' Create one at: https://github.com/settings/tokens');
|
|
41
|
+
console.error(' Required scope: repo\n');
|
|
42
|
+
config.githubToken = await prompt(rl, 'Paste your GitHub personal access token: ');
|
|
43
|
+
if (!config.githubToken) {
|
|
44
|
+
console.error('GitHub token is required.');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!config.slackWebhookUrl) {
|
|
50
|
+
console.error('\n⚠ Slack webhook URL not found.');
|
|
51
|
+
console.error(' Create one at: https://api.slack.com/apps → Incoming Webhooks\n');
|
|
52
|
+
config.slackWebhookUrl = await prompt(rl, 'Paste your Slack Incoming Webhook URL: ');
|
|
53
|
+
if (!config.slackWebhookUrl) {
|
|
54
|
+
console.error('Slack webhook URL is required.');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
if (!config.slackWebhookUrl.startsWith('https://hooks.slack.com/')) {
|
|
58
|
+
console.error('Invalid Slack webhook URL. It should start with https://hooks.slack.com/');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
rl.close();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return config;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function ensureConfig(forceReset = false) {
|
|
70
|
+
let config = forceReset ? null : loadConfig();
|
|
71
|
+
|
|
72
|
+
if (!config || !config.githubToken || !config.slackWebhookUrl) {
|
|
73
|
+
config = await promptForConfig(config || {});
|
|
74
|
+
saveConfig(config);
|
|
75
|
+
console.error('✅ Config saved to ~/.watch-pr\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return config;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
ensureConfig,
|
|
83
|
+
loadConfig,
|
|
84
|
+
saveConfig,
|
|
85
|
+
CONFIG_PATH
|
|
86
|
+
};
|
package/src/github.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const { Octokit } = require('@octokit/rest');
|
|
2
|
+
|
|
3
|
+
function createClient(token) {
|
|
4
|
+
return new Octokit({ auth: token });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fetch required status checks from branch protection rules.
|
|
9
|
+
* Returns array of required check names, or null if unavailable (no permissions or no protection).
|
|
10
|
+
*/
|
|
11
|
+
async function fetchRequiredChecks(octokit, { owner, repo, branch }) {
|
|
12
|
+
try {
|
|
13
|
+
const { data } = await octokit.repos.getBranchProtection({
|
|
14
|
+
owner,
|
|
15
|
+
repo,
|
|
16
|
+
branch
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const checks = data.required_status_checks;
|
|
20
|
+
if (!checks) return null;
|
|
21
|
+
|
|
22
|
+
// GitHub API returns checks in both `contexts` (legacy) and `checks` (new)
|
|
23
|
+
const requiredNames = new Set();
|
|
24
|
+
|
|
25
|
+
if (checks.checks && checks.checks.length > 0) {
|
|
26
|
+
checks.checks.forEach(c => requiredNames.add(c.context));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (checks.contexts && checks.contexts.length > 0) {
|
|
30
|
+
checks.contexts.forEach(c => requiredNames.add(c));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return requiredNames.size > 0 ? requiredNames : null;
|
|
34
|
+
} catch {
|
|
35
|
+
// 403 (no permissions) or 404 (no branch protection) — fall back
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fetch PR data and CI check runs, then classify the state.
|
|
42
|
+
*/
|
|
43
|
+
async function fetchPRState(octokit, { owner, repo, prNumber, requiredCheckNames }) {
|
|
44
|
+
// Fetch PR details
|
|
45
|
+
const { data: pr } = await octokit.pulls.get({
|
|
46
|
+
owner,
|
|
47
|
+
repo,
|
|
48
|
+
pull_number: prNumber
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// If PR is closed, no need to check CI
|
|
52
|
+
if (pr.state === 'closed') {
|
|
53
|
+
return {
|
|
54
|
+
merged: pr.merged,
|
|
55
|
+
closed: true,
|
|
56
|
+
htmlUrl: pr.html_url,
|
|
57
|
+
title: pr.title,
|
|
58
|
+
baseBranch: pr.base.ref,
|
|
59
|
+
headSha: pr.head.sha,
|
|
60
|
+
mergeableState: pr.mergeable_state,
|
|
61
|
+
checks: null
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Fetch check runs for the head SHA
|
|
66
|
+
const { data: checkRunsData } = await octokit.checks.listForRef({
|
|
67
|
+
owner,
|
|
68
|
+
repo,
|
|
69
|
+
ref: pr.head.sha
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Deduplicate check runs — GitHub returns multiple runs for the same check name
|
|
73
|
+
// (e.g. re-runs). Keep only the latest run per check name.
|
|
74
|
+
const latestByName = new Map();
|
|
75
|
+
for (const check of checkRunsData.check_runs) {
|
|
76
|
+
const existing = latestByName.get(check.name);
|
|
77
|
+
if (!existing || new Date(check.started_at) > new Date(existing.started_at)) {
|
|
78
|
+
latestByName.set(check.name, check);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Classify each check as required or not
|
|
83
|
+
const checks = [...latestByName.values()].map(check => {
|
|
84
|
+
const isRequired = requiredCheckNames
|
|
85
|
+
? requiredCheckNames.has(check.name)
|
|
86
|
+
: true; // if we don't know required checks, treat all as required
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
name: check.name,
|
|
90
|
+
status: check.status, // queued, in_progress, completed
|
|
91
|
+
conclusion: check.conclusion, // success, failure, neutral, etc.
|
|
92
|
+
required: isRequired
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const requiredChecks = checks.filter(c => c.required);
|
|
97
|
+
const failedRequired = requiredChecks.filter(c => c.conclusion === 'failure');
|
|
98
|
+
const pendingRequired = requiredChecks.filter(c => c.status !== 'completed');
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
merged: false,
|
|
102
|
+
closed: false,
|
|
103
|
+
htmlUrl: pr.html_url,
|
|
104
|
+
title: pr.title,
|
|
105
|
+
baseBranch: pr.base.ref,
|
|
106
|
+
headSha: pr.head.sha,
|
|
107
|
+
mergeableState: pr.mergeable_state,
|
|
108
|
+
checks: {
|
|
109
|
+
all: checks,
|
|
110
|
+
requiredTotal: requiredChecks.length,
|
|
111
|
+
failedRequired,
|
|
112
|
+
pendingRequired,
|
|
113
|
+
allRequiredPassed: failedRequired.length === 0 && pendingRequired.length === 0 && requiredChecks.length > 0
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
createClient,
|
|
120
|
+
fetchRequiredChecks,
|
|
121
|
+
fetchPRState
|
|
122
|
+
};
|
package/src/slack.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
async function sendNotification(webhookUrl, text) {
|
|
2
|
+
try {
|
|
3
|
+
const response = await fetch(webhookUrl, {
|
|
4
|
+
method: 'POST',
|
|
5
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6
|
+
body: JSON.stringify({ text })
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
console.error(`Slack notification failed: ${response.status} ${response.statusText}`);
|
|
11
|
+
}
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error(`Slack notification error: ${error.message}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatMerged(prUrl, title) {
|
|
18
|
+
return `🎉 *PR was merged!*\n<${prUrl}|${title}>`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatClosed(prUrl, title) {
|
|
22
|
+
return `❌ *PR was closed without merging*\n<${prUrl}|${title}>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatCIFailed(prUrl, title, failedChecks) {
|
|
26
|
+
const checkList = failedChecks.map(c => ` • ${c}`).join('\n');
|
|
27
|
+
return `⚠️ *CI checks failed*\n<${prUrl}|${title}>\n\nFailed required checks:\n${checkList}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatRebaseNeeded(prUrl, title) {
|
|
31
|
+
return `🔄 *PR needs rebase*\n<${prUrl}|${title}>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatReady(prUrl, title) {
|
|
35
|
+
return `✅ *PR is ready to merge!*\n<${prUrl}|${title}>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
sendNotification,
|
|
40
|
+
formatMerged,
|
|
41
|
+
formatClosed,
|
|
42
|
+
formatCIFailed,
|
|
43
|
+
formatRebaseNeeded,
|
|
44
|
+
formatReady
|
|
45
|
+
};
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse GitHub PR URL into components
|
|
3
|
+
* @param {string} url - GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)
|
|
4
|
+
* @returns {object|null} - {owner, repo, prNumber} or null if invalid
|
|
5
|
+
*/
|
|
6
|
+
function parsePRUrl(url) {
|
|
7
|
+
if (!url || typeof url !== 'string') {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/);
|
|
12
|
+
|
|
13
|
+
if (!match) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
owner: match[1],
|
|
19
|
+
repo: match[2],
|
|
20
|
+
prNumber: parseInt(match[3], 10)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build PR key for storage
|
|
26
|
+
* @param {string} owner - Repository owner
|
|
27
|
+
* @param {string} repo - Repository name
|
|
28
|
+
* @param {number} prNumber - PR number
|
|
29
|
+
* @returns {string} - PR key (e.g., "owner/repo#123")
|
|
30
|
+
*/
|
|
31
|
+
function buildPRKey(owner, repo, prNumber) {
|
|
32
|
+
return `${owner}/${repo}#${prNumber}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = {
|
|
36
|
+
parsePRUrl,
|
|
37
|
+
buildPRKey
|
|
38
|
+
};
|