swiftbar-github 0.0.0-stage → 1.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 jryom
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 CHANGED
@@ -1,3 +1,19 @@
1
- # Temporary Holding Version
1
+ # SwiftBar GitHub
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A macOS menu bar plugin for unread GitHub notifications and pending review requests. Refreshes every three minutes and sends desktop alerts for new activity. Hidden when there is nothing pending; the first run is silent.
4
+
5
+ ## Setup
6
+
7
+ Requires macOS, SwiftBar, and Node.js 24.15+ (24.x). `node` and `npx` must be on SwiftBar's `PATH`. To reuse your GitHub CLI login, `gh` must be there too:
8
+
9
+ ```sh
10
+ brew install --cask swiftbar
11
+ brew install gh
12
+ gh auth login --hostname github.com
13
+ ```
14
+
15
+ Download [swiftbar-github.3m.sh](https://cdn.jsdelivr.net/npm/swiftbar-github@latest/swiftbar-github.3m.sh) into your SwiftBar plugin folder.
16
+
17
+ Run `chmod +x swiftbar-github.3m.sh`. Updates resolve through `@latest` on each refresh; downloaded versions are cached. For authentication without `gh`, set `GH_TOKEN` or `GITHUB_TOKEN` in SwiftBar's environment.
18
+
19
+ Allow notifications for the bundled terminal-notifier in macOS System Settings. Click entries to open them on GitHub.
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
2
+ <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-2.14c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.75 1.18 1.75 1.18 1.02 1.75 2.69 1.24 3.34.95.1-.74.4-1.24.73-1.53-2.55-.29-5.23-1.28-5.23-5.69 0-1.25.45-2.28 1.18-3.08-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.16 1.18a11 11 0 0 1 5.75 0c2.2-1.49 3.16-1.18 3.16-1.18.62 1.58.23 2.75.11 3.04.73.8 1.18 1.83 1.18 3.08 0 4.42-2.69 5.4-5.25 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.79.56A11.5 11.5 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5z"/>
3
+ </svg>
package/bin/cli.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/swiftbar-github.js";
@@ -0,0 +1,233 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { Octokit } from "@octokit/rest";
7
+ import nodeNotifier from "node-notifier";
8
+ const exec = promisify(execFile);
9
+ const notifier = new nodeNotifier.NotificationCenter({ withFallback: false });
10
+ const inbox = "https://github.com/notifications";
11
+ const launcher = process.env.SWIFTBAR_PLUGIN_PATH;
12
+ const cache = process.env.SWIFTBAR_PLUGIN_CACHE_PATH ||
13
+ join(homedir(), ".cache/swiftbar", basename(launcher || "swiftbar-github.3m.sh"));
14
+ const icon = (await readFile(new URL("../assets/github.svg", import.meta.url))).toString("base64");
15
+ function text(value) {
16
+ if (typeof value !== "string")
17
+ throw new Error("GitHub returned invalid notification data.");
18
+ return value.replace(/[|\r\n\t]/g, " ");
19
+ }
20
+ async function token() {
21
+ const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
22
+ if (value)
23
+ return value;
24
+ try {
25
+ const { stdout } = await exec("gh", ["auth", "token", "--hostname", "github.com"], { timeout: 10000 });
26
+ if (stdout.trim())
27
+ return stdout.trim();
28
+ }
29
+ catch (error) {
30
+ // Child process errors can contain the token, so never surface them.
31
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
32
+ throw new Error("GitHub CLI (gh) is not installed.");
33
+ }
34
+ throw new Error("Sign in with gh auth login --hostname github.com.");
35
+ }
36
+ function webURL(notification) {
37
+ const { subject, repository } = notification;
38
+ if (subject.type === "Release")
39
+ return `${repository.html_url}/releases`;
40
+ if (!subject.url)
41
+ return inbox;
42
+ if (!["PullRequest", "Issue", "Discussion", "Commit"].includes(subject.type))
43
+ return inbox;
44
+ const url = new URL(subject.url);
45
+ if (url.origin !== "https://api.github.com" ||
46
+ !url.pathname.startsWith("/repos/"))
47
+ return inbox;
48
+ return `https://github.com/${url.pathname.slice("/repos/".length).replace("/pulls/", "/pull/").replace("/commits/", "/commit/")}`;
49
+ }
50
+ async function state(file) {
51
+ try {
52
+ return new Set((await readFile(file, "utf8")).split("\n"));
53
+ }
54
+ catch (error) {
55
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
56
+ return null;
57
+ throw error;
58
+ }
59
+ }
60
+ async function save(file, values) {
61
+ const temporary = `${file}.${process.pid}`;
62
+ try {
63
+ await writeFile(temporary, values.length ? `${values.join("\n")}\n` : "", {
64
+ mode: 0o600,
65
+ });
66
+ await rename(temporary, file);
67
+ }
68
+ finally {
69
+ await rm(temporary, { force: true });
70
+ }
71
+ }
72
+ function notify(item) {
73
+ return new Promise((resolve) => {
74
+ const notification = {
75
+ title: item.alertTitle,
76
+ subtitle: item.repository,
77
+ message: item.title,
78
+ open: item.url,
79
+ sound: "default",
80
+ group: item.group,
81
+ timeout: false,
82
+ };
83
+ notifier.notify(notification, (error) => resolve(!error));
84
+ });
85
+ }
86
+ function action(label, argument) {
87
+ if (!launcher)
88
+ return `${label} | href=${inbox}`;
89
+ return `${label} | bash="${launcher.replaceAll('"', '\\"')}" param1=${argument} terminal=false`;
90
+ }
91
+ async function fetchActivity(argument) {
92
+ const github = new Octokit({
93
+ auth: await token(),
94
+ userAgent: "swiftbar-github",
95
+ request: {
96
+ redirect: "error",
97
+ fetch: (url, options) => fetch(url, {
98
+ ...options,
99
+ signal: options?.signal
100
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(20000)])
101
+ : AbortSignal.timeout(20000),
102
+ }),
103
+ },
104
+ log: { debug() { }, info() { }, warn() { }, error() { } },
105
+ });
106
+ const { data: user } = await github.rest.users.getAuthenticated();
107
+ const requested = await github.paginate(github.rest.search.issuesAndPullRequests, {
108
+ q: `is:pr is:open archived:false review-requested:${user.login}`,
109
+ per_page: 100,
110
+ }, (response) => {
111
+ if (response.data.incomplete_results)
112
+ throw new Error("GitHub returned incomplete review search results.");
113
+ if (response.data.total_count > 1000)
114
+ throw new Error("More than 1,000 review requests matched. GitHub search cannot return all of them.");
115
+ return response.data;
116
+ });
117
+ const reviews = requested.map((item) => {
118
+ const repository = new URL(item.repository_url).pathname.slice("/repos/".length);
119
+ const title = `#${item.number} ${text(item.title)}`;
120
+ return {
121
+ url: item.html_url,
122
+ repository,
123
+ title,
124
+ key: item.html_url,
125
+ group: `github-review-${item.html_url}`,
126
+ alertTitle: "Review requested",
127
+ };
128
+ });
129
+ if (argument === "open-reviews")
130
+ return { reviews, notifications: [] };
131
+ const reviewURLs = new Set(reviews.map((item) => item.url));
132
+ const unread = await github.paginate(github.rest.activity.listNotificationsForAuthenticatedUser, {
133
+ all: false,
134
+ participating: false,
135
+ per_page: 50,
136
+ });
137
+ const notifications = unread
138
+ .map((item) => ({ ...item, url: webURL(item) }))
139
+ .filter((item) => item.reason !== "review_requested" || !reviewURLs.has(item.url))
140
+ .map((item) => {
141
+ const repository = text(item.repository.full_name);
142
+ const reason = text(item.reason).replaceAll("_", " ");
143
+ const title = text(item.subject.title);
144
+ return {
145
+ key: `${item.id}:${item.updated_at}`,
146
+ repository,
147
+ title,
148
+ url: item.url,
149
+ group: `github-notification-${item.id}`,
150
+ alertTitle: `GitHub: ${reason}`,
151
+ reason,
152
+ };
153
+ });
154
+ return { reviews, notifications };
155
+ }
156
+ function renderMenu(reviews, notifications) {
157
+ const total = reviews.length + notifications.length;
158
+ if (!total)
159
+ return "";
160
+ const menu = [
161
+ `${total} | templateImage=${icon}`,
162
+ "---",
163
+ `Review requests (${reviews.length}) | color=gray`,
164
+ ];
165
+ if (reviews.length) {
166
+ for (const item of reviews)
167
+ menu.push(`${item.repository} ${item.title} | href=${item.url}`);
168
+ menu.push(action("Open all review requests", "open-reviews"));
169
+ }
170
+ else
171
+ menu.push("No reviews requested | color=gray");
172
+ menu.push("---", `Notifications (${notifications.length}) | color=gray`);
173
+ if (notifications.length) {
174
+ for (const item of notifications)
175
+ menu.push(`${item.repository} [${item.reason}]: ${item.title} | href=${item.url}`);
176
+ menu.push(action("Open all notifications", "open-notifications"));
177
+ }
178
+ else
179
+ menu.push("No unread notifications | color=gray");
180
+ return menu.join("\n");
181
+ }
182
+ async function deliverAlerts(file, items) {
183
+ await mkdir(cache, { recursive: true });
184
+ const path = join(cache, file);
185
+ const seen = await state(path);
186
+ const current = [];
187
+ for (const item of items) {
188
+ if (!seen || seen.has(item.key) || (await notify(item)))
189
+ current.push(item.key);
190
+ }
191
+ await save(path, current);
192
+ }
193
+ async function main() {
194
+ const argument = process.argv[2];
195
+ if (argument && !["open-reviews", "open-notifications"].includes(argument))
196
+ throw new Error("Usage: swiftbar-github");
197
+ const { reviews, notifications } = await fetchActivity(argument);
198
+ if (argument) {
199
+ const items = argument === "open-reviews" ? reviews : notifications;
200
+ for (const item of items)
201
+ await exec("/usr/bin/open", ["-g", item.url]);
202
+ return;
203
+ }
204
+ const menu = renderMenu(reviews, notifications);
205
+ await deliverAlerts("requested-prs", reviews);
206
+ await deliverAlerts("inbox-notifications", notifications);
207
+ if (menu)
208
+ console.log(menu);
209
+ }
210
+ try {
211
+ await main();
212
+ }
213
+ catch (error) {
214
+ const status = error instanceof Error &&
215
+ "status" in error &&
216
+ typeof error.status === "number"
217
+ ? error.status
218
+ : null;
219
+ const message = status === 401
220
+ ? "GitHub authentication expired. Sign in again with gh auth login."
221
+ : status
222
+ ? `GitHub request failed (HTTP ${status}). Check token permissions and rate limits.`
223
+ : error instanceof Error
224
+ ? error.message
225
+ : "Could not retrieve GitHub notifications.";
226
+ if (process.argv[2]) {
227
+ console.error(message);
228
+ process.exitCode = 1;
229
+ }
230
+ else {
231
+ console.log(`? | color=red templateImage=${icon} href=${inbox}\n---\n${text(message)}`);
232
+ }
233
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
- "name": "swiftbar-github",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "swiftbar-github",
3
+ "version": "1.0.0",
4
+ "description": "GitHub notifications and review requests in the macOS menu bar",
5
+ "license": "MIT",
6
+ "repository": "github:jryom/swiftbar-github",
7
+ "type": "module",
8
+ "bin": "bin/cli.js",
9
+ "engines": {
10
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "dist",
15
+ "assets",
16
+ "swiftbar-github.3m.sh"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "format": "biome format --write .",
21
+ "prepack": "tsc"
22
+ },
23
+ "dependencies": {
24
+ "@octokit/rest": "^22.0.1",
25
+ "node-notifier": "^11.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@biomejs/biome": "^2.5.15",
29
+ "@types/node": "^26.6.5",
30
+ "@types/node-notifier": "^8.0.5",
31
+ "typescript": "^7.0.2"
32
+ }
33
+ }
@@ -0,0 +1,10 @@
1
+ #!/bin/bash
2
+
3
+ if output=$(npx --yes swiftbar-github@latest "$@" 2>/dev/null); then
4
+ [[ -z "$output" ]] || printf '%s\n' "$output"
5
+ elif [[ $# -eq 0 ]]; then
6
+ printf '%s\n' 'GitHub unavailable | color=red' '---' 'Could not start the plugin.'
7
+ else
8
+ echo 'Could not start the plugin.' >&2
9
+ exit 1
10
+ fi