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 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
@@ -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
+ };