swiftbar-github 1.0.1 → 1.1.1

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/README.md CHANGED
@@ -16,4 +16,6 @@ Download [swiftbar-github.3m.sh](https://cdn.jsdelivr.net/npm/swiftbar-github@la
16
16
 
17
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
18
 
19
- Allow notifications for the bundled terminal-notifier in macOS System Settings. Click entries to open them on GitHub.
19
+ Allow notifications for terminal-notifier in macOS System Settings. Click entries or desktop notifications to open them on GitHub; desktop notification clicks are available for five minutes.
20
+
21
+ Use **Mark as read** to clear unread threads by type or reason. This does not complete pending review requests. Option-click the menu to access SwiftBar's hidden controls.
@@ -1,12 +1,12 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
3
4
  import { homedir } from "node:os";
4
- import { basename, join } from "node:path";
5
+ import { basename, dirname, join } from "node:path";
5
6
  import { promisify } from "node:util";
6
7
  import { Octokit } from "@octokit/rest";
7
- import nodeNotifier from "node-notifier";
8
8
  const exec = promisify(execFile);
9
- const notifier = new nodeNotifier.NotificationCenter({ withFallback: false });
9
+ const notifier = join(dirname(createRequire(import.meta.url).resolve("node-notifier")), "vendor/mac.noindex/terminal-notifier.app/Contents/MacOS/terminal-notifier");
10
10
  const inbox = "https://github.com/notifications";
11
11
  const launcher = process.env.SWIFTBAR_PLUGIN_PATH;
12
12
  const cache = process.env.SWIFTBAR_PLUGIN_CACHE_PATH ||
@@ -71,25 +71,38 @@ async function save(file, values) {
71
71
  }
72
72
  function notify(item) {
73
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));
74
+ const child = spawn(notifier, [
75
+ "-title",
76
+ item.alertTitle,
77
+ "-subtitle",
78
+ item.repository,
79
+ "-message",
80
+ item.title,
81
+ "-open",
82
+ item.url,
83
+ "-group",
84
+ `github-${item.url}`,
85
+ "-sound",
86
+ "default",
87
+ "-timeout",
88
+ "300",
89
+ ], { detached: true, stdio: "ignore" });
90
+ child.once("error", () => resolve(false));
91
+ child.once("spawn", () => {
92
+ // Record the handoff, not the eventual click or timeout.
93
+ child.unref();
94
+ resolve(true);
95
+ });
84
96
  });
85
97
  }
86
- function action(label, argument) {
98
+ function action(label, args, refresh = false) {
87
99
  if (!launcher)
88
100
  return `${label} | href=${inbox}`;
89
- return `${label} | bash="${launcher.replaceAll('"', '\\"')}" param1=${argument} terminal=false`;
101
+ const params = args.map((arg, index) => `param${index + 1}=${arg}`).join(" ");
102
+ return `${label} | bash="${launcher.replaceAll('"', '\\"')}" ${params} terminal=false${refresh ? " refresh=true" : ""}`;
90
103
  }
91
- async function fetchActivity(argument) {
92
- const github = new Octokit({
104
+ async function client() {
105
+ return new Octokit({
93
106
  auth: await token(),
94
107
  userAgent: "swiftbar-github",
95
108
  request: {
@@ -103,6 +116,9 @@ async function fetchActivity(argument) {
103
116
  },
104
117
  log: { debug() { }, info() { }, warn() { }, error() { } },
105
118
  });
119
+ }
120
+ async function fetchActivity(argument) {
121
+ const github = await client();
106
122
  const { data: user } = await github.rest.users.getAuthenticated();
107
123
  const requested = await github.paginate(github.rest.search.issuesAndPullRequests, {
108
124
  q: `is:pr is:open archived:false review-requested:${user.login}`,
@@ -122,12 +138,11 @@ async function fetchActivity(argument) {
122
138
  repository,
123
139
  title,
124
140
  key: item.html_url,
125
- group: `github-review-${item.html_url}`,
126
141
  alertTitle: "Review requested",
127
142
  };
128
143
  });
129
144
  if (argument === "open-reviews")
130
- return { reviews, notifications: [] };
145
+ return { reviews, notifications: [], unread: [] };
131
146
  const reviewURLs = new Set(reviews.map((item) => item.url));
132
147
  const unread = await github.paginate(github.rest.activity.listNotificationsForAuthenticatedUser, {
133
148
  all: false,
@@ -146,26 +161,25 @@ async function fetchActivity(argument) {
146
161
  repository,
147
162
  title,
148
163
  url: item.url,
149
- group: `github-notification-${item.id}`,
150
164
  alertTitle: `GitHub: ${reason}`,
151
165
  reason,
152
166
  };
153
167
  });
154
- return { reviews, notifications };
168
+ return { reviews, notifications, unread };
155
169
  }
156
- function renderMenu(reviews, notifications) {
170
+ function renderMenu(reviews, notifications, unread) {
157
171
  const total = reviews.length + notifications.length;
158
172
  if (!total)
159
173
  return "";
160
174
  const menu = [
161
- `${total} | templateImage=${icon}`,
175
+ `${process.env.SWIFTBAR_GITHUB_DEV ? "Dev " : ""}${total} | templateImage=${icon}`,
162
176
  "---",
163
177
  `Review requests (${reviews.length}) | color=gray`,
164
178
  ];
165
179
  if (reviews.length) {
166
180
  for (const item of reviews)
167
181
  menu.push(`${item.repository} ${item.title} | href=${item.url}`);
168
- menu.push(action("Open all review requests", "open-reviews"));
182
+ menu.push(action("Open all review requests", ["open-reviews"]));
169
183
  }
170
184
  else
171
185
  menu.push("No reviews requested | color=gray");
@@ -173,37 +187,89 @@ function renderMenu(reviews, notifications) {
173
187
  if (notifications.length) {
174
188
  for (const item of notifications)
175
189
  menu.push(`${item.repository} [${item.reason}]: ${item.title} | href=${item.url}`);
176
- menu.push(action("Open all notifications", "open-notifications"));
190
+ menu.push(action("Open all notifications", ["open-notifications"]));
177
191
  }
178
192
  else
179
193
  menu.push("No unread notifications | color=gray");
194
+ if (unread.length) {
195
+ const types = {
196
+ PullRequest: "Pull requests",
197
+ Issue: "Issues",
198
+ Discussion: "Discussions",
199
+ Commit: "Commits",
200
+ Release: "Releases",
201
+ };
202
+ menu.push("---", "Mark as read");
203
+ menu.push(`--${action(`All notifications (${unread.length})`, ["mark-read", unread.map((item) => item.id).join(",")], true)}`);
204
+ for (const [heading, labelFor] of [
205
+ [
206
+ "By type",
207
+ (item) => text(types[item.subject.type] || item.subject.type),
208
+ ],
209
+ [
210
+ "By reason",
211
+ (item) => text(item.reason).replaceAll("_", " "),
212
+ ],
213
+ ]) {
214
+ const groups = new Map();
215
+ for (const item of unread) {
216
+ const label = labelFor(item);
217
+ const ids = groups.get(label) || [];
218
+ ids.push(item.id);
219
+ groups.set(label, ids);
220
+ }
221
+ menu.push("-----", `--${heading} | color=gray`);
222
+ for (const [label, ids] of [...groups].sort(([a], [b]) => a.localeCompare(b)))
223
+ menu.push(`--${action(`${label} (${ids.length})`, ["mark-read", ids.join(",")], true)}`);
224
+ }
225
+ }
180
226
  return menu.join("\n");
181
227
  }
182
- async function deliverAlerts(file, items) {
228
+ async function deliverAlerts(file, items, represented = []) {
183
229
  await mkdir(cache, { recursive: true });
184
230
  const path = join(cache, file);
185
231
  const seen = await state(path);
186
- const current = [];
232
+ const current = new Set(represented);
187
233
  for (const item of items) {
234
+ if (current.has(item.key))
235
+ continue;
188
236
  if (!seen || seen.has(item.key) || (await notify(item)))
189
- current.push(item.key);
237
+ current.add(item.key);
190
238
  }
191
- await save(path, current);
239
+ await save(path, [...current]);
192
240
  }
193
241
  async function main() {
194
242
  const argument = process.argv[2];
195
- if (argument && !["open-reviews", "open-notifications"].includes(argument))
243
+ if (argument &&
244
+ !["open-reviews", "open-notifications", "mark-read"].includes(argument))
196
245
  throw new Error("Usage: swiftbar-github");
197
- const { reviews, notifications } = await fetchActivity(argument);
246
+ if (argument === "mark-read") {
247
+ const ids = (process.argv[3] || "").split(",").map(Number);
248
+ if (ids.some((id) => !Number.isSafeInteger(id) || id <= 0))
249
+ throw new Error("Usage: swiftbar-github mark-read <thread-ids>");
250
+ const github = await client();
251
+ for (const id of ids)
252
+ await github.rest.activity.markThreadAsRead({ thread_id: id });
253
+ return;
254
+ }
255
+ const { reviews, notifications, unread } = await fetchActivity(argument);
198
256
  if (argument) {
199
257
  const items = argument === "open-reviews" ? reviews : notifications;
200
258
  for (const item of items)
201
259
  await exec("/usr/bin/open", ["-g", item.url]);
202
260
  return;
203
261
  }
204
- const menu = renderMenu(reviews, notifications);
262
+ const menu = renderMenu(reviews, notifications, unread);
263
+ const previousReviews = await state(join(cache, "requested-prs"));
264
+ const reviewURLs = new Set([
265
+ ...reviews.map((item) => item.url),
266
+ ...(previousReviews || []),
267
+ ]);
268
+ const represented = unread
269
+ .filter((item) => item.reason === "review_requested" && reviewURLs.has(webURL(item)))
270
+ .map((item) => `${item.id}:${item.updated_at}`);
205
271
  await deliverAlerts("requested-prs", reviews);
206
- await deliverAlerts("inbox-notifications", notifications);
272
+ await deliverAlerts("inbox-notifications", notifications, represented);
207
273
  if (menu)
208
274
  console.log(menu);
209
275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swiftbar-github",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "GitHub notifications and review requests in the macOS menu bar",
5
5
  "license": "MIT",
6
6
  "repository": "github:jryom/swiftbar-github",
@@ -27,7 +27,6 @@
27
27
  "devDependencies": {
28
28
  "@biomejs/biome": "^2.5.15",
29
29
  "@types/node": "^26.6.5",
30
- "@types/node-notifier": "^8.0.5",
31
30
  "typescript": "^7.0.2"
32
31
  }
33
32
  }
@@ -1,4 +1,9 @@
1
1
  #!/bin/bash
2
+ # <swiftbar.hideAbout>true</swiftbar.hideAbout>
3
+ # <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>
4
+ # <swiftbar.hideLastUpdated>true</swiftbar.hideLastUpdated>
5
+ # <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>
6
+ # <swiftbar.hideSwiftBar>true</swiftbar.hideSwiftBar>
2
7
 
3
8
  if output=$(npx --yes swiftbar-github@latest "$@" 2>/dev/null); then
4
9
  [[ -z "$output" ]] || printf '%s\n' "$output"