diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..603e603 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm typecheck && pnpm lint && pnpm format:check && pnpm test -- --run diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c801c1..3b78da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] - 2026-05-09 + +### Added + +- `ghitgud gh` passthrough command — proxy any args to the gh CLI +- `notifications list` with `--all`, `--participating`, `--repo`, `--limit` +- `notifications read ` +- `notifications done ` +- `activity` — composite view of assigned issues, review requests, mentions +- `mentions` — search for recent @mentions +- `client.put` method in API layer + ## [2.0.0] - 2025-05-09 ### Added diff --git a/README.md b/README.md index db979b5..7392b9a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,15 @@ ghitgud config get repo ## Commands ``` +ghitgud gh Pass through to the gh CLI +ghitgud notifications list List notifications +ghitgud notifications list -a Include read notifications +ghitgud notifications list -p Only participating +ghitgud notifications list -r owner/repo Filter by repository +ghitgud notifications read Mark a notification as read +ghitgud notifications done Mark a notification as done +ghitgud activity Assigned issues, review requests, mentions +ghitgud mentions Recent @mentions of you ghitgud ping Check if the CLI is working ghitgud labels list List all labels for a repository ghitgud labels pull Pull labels from a repository to local config diff --git a/ROADMAP.md b/ROADMAP.md index 00420ee..59721e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,36 +1,7 @@ # Ghitgud Roadmap — Superset Features gh CLI Doesn't Have > Compiled from deep research of the `cli/cli` repository, community extensions, and top user requests. -> Current ghitgud version: **2.0.0** (labels + config + templates) - ---- - -## Architecture Principle — `ghitgud gh` Passthrough - -Ghitgud provides a `ghitgud gh` subcommand that transparently passes all arguments through to the underlying `gh` CLI. This means: - -- `ghitgud gh pr create` calls `gh pr create` -- `ghitgud gh repo clone airscripts/ghitgud` calls `gh repo clone airscripts/ghitgud` -- Users who want ghitgud's superpowers keep `ghitgud` in their muscle memory -- GitHub's official CLI stays the engine; ghitgud is the supercharger -- No ambiguity — ghitgud native commands and gh passthrough are cleanly separated - -**Implementation:** A `gh` subcommand registered in Commander that shells out to `gh` with all trailing args, preserving stdin/stdout/stderr and exit codes. - ---- - -## v2.1.0 — Notifications & Activity Triage - -**Why gh doesn't have it:** Issue #659 open since March 2020. No native `gh notification` commands exist. Users rely on browser or third-party extensions like `gh-notify`. - -**Commands:** -- `ghitgud notifications list --unread --participating --repo ` -- `ghitgud notifications mark-read ` -- `ghitgud notifications mark-done ` -- `ghitgud activity` — assigned issues, review requests, mentions across all repos -- `ghitgud mentions` — find all @mentions of you - -**Value:** Daily driver feature. Most developers check GitHub notifications multiple times per day. Doing it from the terminal without context switching is a genuine superpower. +> Current ghitgud version: **2.1.0** (labels + config + templates + notifications + activity + mentions + gh passthrough) --- @@ -39,6 +10,7 @@ Ghitgud provides a `ghitgud gh` subcommand that transparently passes all argumen **Why gh doesn't have it:** Issues #380 (cleanup, Feb 2020) and #2189 (pr push, Sep 2020) are among the most upvoted open issues. Extension `gh-poi` and `gh-stack` fill partial gaps but no official solution exists. **Commands:** + - `ghitgud pr cleanup` — delete merged branches locally and remotely, fast-forward base branch, handle squash/rebase safely - `ghitgud pr push` — push changes back to a contributor's fork after `gh pr checkout` - `ghitgud pr stack` — manage stacked PRs (create/update dependent chains) @@ -53,6 +25,7 @@ Ghitgud provides a `ghitgud gh` subcommand that transparently passes all argumen **Why gh doesn't have it:** Issue #326 is the #1 most requested feature (open since Feb 2020). Users with work + personal accounts currently use shell scripts, env vars, or separate config files. **Commands:** + - `ghitgud profile switch ` — switch active account instantly - `ghitgud profile list` — show all configured profiles - `ghitgud profile add --token ` — add new profile @@ -69,6 +42,7 @@ Ghitgud provides a `ghitgud gh` subcommand that transparently passes all argumen **Why gh doesn't have it:** `gh` operates on single repos only. No bulk operations across organizations or repo lists. Enterprise users write custom scripts. **Commands:** + - `ghitgud repos audit` — find repos missing LICENSE, CODEOWNERS, README, SECURITY.md - `ghitgud repos apply-ruleset` — apply branch protection/ruleset across multiple repos - `ghitgud repos sync-labels` — push label templates across a whole org @@ -84,6 +58,7 @@ Ghitgud provides a `ghitgud gh` subcommand that transparently passes all argumen **Why gh doesn't have it:** Issue #9125 (cache download, May 2024) and no workflow validation/dry-run support. Debugging CI failures requires browser navigation and guesswork. **Commands:** + - `ghitgud workflow validate` — lint workflow YAML against GitHub's schema before pushing - `ghitgud workflow dry-run` — preview job matrix, runner selection, execution path - `ghitgud cache download ` — download Actions cache artifact for local debugging @@ -99,6 +74,7 @@ Ghitgud provides a `ghitgud gh` subcommand that transparently passes all argumen **Why gh doesn't have it:** Issue #359 (fine-grained review, Feb 2020) — `gh pr review` only supports approve/request-changes/comment. No line-specific comments, no thread management. **Commands:** + - `ghitgud review comment --file --line --body --pr ` - `ghitgud review threads ` — list all review threads with resolution status - `ghitgud review resolve ` — mark a thread as resolved @@ -114,6 +90,7 @@ Ghitgud provides a `ghitgud gh` subcommand that transparently passes all argumen **Why gh doesn't have it:** `gh` outputs flat text only. Extension `gh-dash` (very popular) proves massive demand for a rich terminal UI, but it's external and limited. **Commands:** + - `ghitgud tui` — launch full-screen terminal UI - Browse PRs/issues with keyboard navigation (vim bindings) - View diffs with syntax highlighting in-terminal @@ -130,6 +107,7 @@ Ghitgud provides a `ghitgud gh` subcommand that transparently passes all argumen **Why gh doesn't have it:** `gh project` commands are basic and new. No milestone commands exist. Sub-task support (issue #10298) was only added to the API in 2025 and has no CLI support. **Commands:** + - `ghitgud milestone create --title --due-date ` - `ghitgud milestone list --status open|closed` - `ghitgud milestone close ` @@ -147,6 +125,7 @@ Ghitgud provides a `ghitgud gh` subcommand that transparently passes all argumen **Why gh doesn't have it:** `gh release create --generate-notes` exists but has no conventional commit support, no auto-versioning, no changelog templates. Teams write custom release scripts. **Commands:** + - `ghitgud release changelog` — generate changelog from conventional commits since last tag - `ghitgud release bump` — auto-detect next semver from commit types (feat → minor, fix → patch, BREAKING → major) - `ghitgud release verify` — check attestation, signatures, and artifact integrity @@ -162,6 +141,7 @@ Ghitgud provides a `ghitgud gh` subcommand that transparently passes all argumen **Why gh doesn't have it:** Enterprise audit logs are API-only. No secret scanning management in CLI. Dependabot alerts require browser. Platform engineers need terminal access for compliance workflows. **Commands:** + - `ghitgud audit-log` — query enterprise audit events with filters (actor, action, repo, date range) - `ghitgud secrets scan` — scan repo history for leaked secrets (integrate with GitHub secret scanning API) - `ghitgud secrets alerts` — list secret scanning alerts per repo diff --git a/VERSION b/VERSION index 359a5b9..7ec1d6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0 \ No newline at end of file +2.1.0 diff --git a/package.json b/package.json index 54dcf4b..003bbf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@airscript/ghitgud", - "version": "2.0.0", + "version": "2.1.0", "description": "A simple CLI to give superpowers to GitHub.", "main": "dist/index.js", "files": [ @@ -30,7 +30,8 @@ "lint": "eslint src/ tests/", "format": "prettier --write .", "format:check": "prettier --check .", - "clean": "rm -rf dist coverage" + "clean": "rm -rf dist coverage", + "prepare": "husky && pnpm build" }, "repository": { "type": "git", @@ -49,6 +50,7 @@ "@vitest/coverage-v8": "^3.2.4", "eslint": "10.3.0", "eslint-config-prettier": "10.1.8", + "husky": "9.1.7", "prettier": "3.8.3", "typescript": "^5.8.3", "typescript-eslint": "8.59.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d1d1d0..9dc2a65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,13 +31,16 @@ importers: version: 24.0.0 "@vitest/coverage-v8": specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.0.0)(lightningcss@1.32.0)) + version: 3.2.4(vitest@3.2.4(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4)) eslint: specifier: 10.3.0 version: 10.3.0 eslint-config-prettier: specifier: 10.1.8 version: 10.1.8(eslint@10.3.0) + husky: + specifier: 9.1.7 + version: 9.1.7 prettier: specifier: 3.8.3 version: 3.8.3 @@ -49,10 +52,10 @@ importers: version: 8.59.2(eslint@10.3.0)(typescript@5.8.3) vite: specifier: ^8.0.11 - version: 8.0.11(@types/node@24.0.0) + version: 8.0.11(@types/node@24.0.0)(yaml@2.8.4) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@24.0.0)(lightningcss@1.32.0) + version: 3.2.4(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4) packages: "@ampproject/remapping@2.3.0": @@ -553,6 +556,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] + libc: [glibc] "@rolldown/binding-linux-arm64-musl@1.0.0-rc.18": resolution: @@ -562,6 +566,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [arm64] os: [linux] + libc: [musl] "@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18": resolution: @@ -571,6 +576,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [ppc64] os: [linux] + libc: [glibc] "@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18": resolution: @@ -580,6 +586,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [s390x] os: [linux] + libc: [glibc] "@rolldown/binding-linux-x64-gnu@1.0.0-rc.18": resolution: @@ -589,6 +596,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] + libc: [glibc] "@rolldown/binding-linux-x64-musl@1.0.0-rc.18": resolution: @@ -598,6 +606,7 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } cpu: [x64] os: [linux] + libc: [musl] "@rolldown/binding-openharmony-arm64@1.0.0-rc.18": resolution: @@ -695,6 +704,7 @@ packages: } cpu: [arm] os: [linux] + libc: [glibc] "@rollup/rollup-linux-arm-musleabihf@4.43.0": resolution: @@ -703,6 +713,7 @@ packages: } cpu: [arm] os: [linux] + libc: [musl] "@rollup/rollup-linux-arm64-gnu@4.43.0": resolution: @@ -711,6 +722,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-arm64-musl@4.43.0": resolution: @@ -719,6 +731,7 @@ packages: } cpu: [arm64] os: [linux] + libc: [musl] "@rollup/rollup-linux-loongarch64-gnu@4.43.0": resolution: @@ -727,6 +740,7 @@ packages: } cpu: [loong64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-powerpc64le-gnu@4.43.0": resolution: @@ -735,6 +749,7 @@ packages: } cpu: [ppc64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-riscv64-gnu@4.43.0": resolution: @@ -743,6 +758,7 @@ packages: } cpu: [riscv64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-riscv64-musl@4.43.0": resolution: @@ -751,6 +767,7 @@ packages: } cpu: [riscv64] os: [linux] + libc: [musl] "@rollup/rollup-linux-s390x-gnu@4.43.0": resolution: @@ -759,6 +776,7 @@ packages: } cpu: [s390x] os: [linux] + libc: [glibc] "@rollup/rollup-linux-x64-gnu@4.43.0": resolution: @@ -767,6 +785,7 @@ packages: } cpu: [x64] os: [linux] + libc: [glibc] "@rollup/rollup-linux-x64-musl@4.43.0": resolution: @@ -775,6 +794,7 @@ packages: } cpu: [x64] os: [linux] + libc: [musl] "@rollup/rollup-win32-arm64-msvc@4.43.0": resolution: @@ -1438,6 +1458,14 @@ packages: integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, } + husky@9.1.7: + resolution: + { + integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==, + } + engines: { node: ">=18" } + hasBin: true + ignore@5.3.2: resolution: { @@ -1616,6 +1644,7 @@ packages: engines: { node: ">= 12.0.0" } cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: @@ -1625,6 +1654,7 @@ packages: engines: { node: ">= 12.0.0" } cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: @@ -1634,6 +1664,7 @@ packages: engines: { node: ">= 12.0.0" } cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: @@ -1643,6 +1674,7 @@ packages: engines: { node: ">= 12.0.0" } cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: @@ -2232,6 +2264,14 @@ packages: } engines: { node: ">=12" } + yaml@2.8.4: + resolution: + { + integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==, + } + engines: { node: ">= 14.6" } + hasBin: true + yocto-queue@0.1.0: resolution: { @@ -2665,7 +2705,7 @@ snapshots: "@typescript-eslint/types": 8.59.2 eslint-visitor-keys: 5.0.1 - "@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.0.0)(lightningcss@1.32.0))": + "@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4))": dependencies: "@ampproject/remapping": 2.3.0 "@bcoe/v8-coverage": 1.0.2 @@ -2680,7 +2720,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.0.0)(lightningcss@1.32.0) + vitest: 3.2.4(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -2692,13 +2732,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - "@vitest/mocker@3.2.4(vite@6.3.5(@types/node@24.0.0)(lightningcss@1.32.0))": + "@vitest/mocker@3.2.4(vite@6.3.5(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4))": dependencies: "@vitest/spy": 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.3.5(@types/node@24.0.0)(lightningcss@1.32.0) + vite: 6.3.5(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4) "@vitest/pretty-format@3.2.4": dependencies: @@ -2978,6 +3018,8 @@ snapshots: html-escaper@2.0.2: {} + husky@9.1.7: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3321,13 +3363,13 @@ snapshots: dependencies: punycode: 2.3.1 - vite-node@3.2.4(@types/node@24.0.0)(lightningcss@1.32.0): + vite-node@3.2.4(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@24.0.0)(lightningcss@1.32.0) + vite: 6.3.5(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - "@types/node" - jiti @@ -3342,7 +3384,7 @@ snapshots: - tsx - yaml - vite@6.3.5(@types/node@24.0.0)(lightningcss@1.32.0): + vite@6.3.5(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: esbuild: 0.25.5 fdir: 6.5.0(picomatch@4.0.4) @@ -3354,8 +3396,9 @@ snapshots: "@types/node": 24.0.0 fsevents: 2.3.3 lightningcss: 1.32.0 + yaml: 2.8.4 - vite@8.0.11(@types/node@24.0.0): + vite@8.0.11(@types/node@24.0.0)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -3365,12 +3408,13 @@ snapshots: optionalDependencies: "@types/node": 24.0.0 fsevents: 2.3.3 + yaml: 2.8.4 - vitest@3.2.4(@types/node@24.0.0)(lightningcss@1.32.0): + vitest@3.2.4(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: "@types/chai": 5.2.2 "@vitest/expect": 3.2.4 - "@vitest/mocker": 3.2.4(vite@6.3.5(@types/node@24.0.0)(lightningcss@1.32.0)) + "@vitest/mocker": 3.2.4(vite@6.3.5(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4)) "@vitest/pretty-format": 3.2.4 "@vitest/runner": 3.2.4 "@vitest/snapshot": 3.2.4 @@ -3388,8 +3432,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@24.0.0)(lightningcss@1.32.0) - vite-node: 3.2.4(@types/node@24.0.0)(lightningcss@1.32.0) + vite: 6.3.5(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite-node: 3.2.4(@types/node@24.0.0)(lightningcss@1.32.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 24.0.0 @@ -3430,4 +3474,7 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + yaml@2.8.4: + optional: true + yocto-queue@0.1.0: {} diff --git a/src/api/client.ts b/src/api/client.ts index 6952532..a617e40 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -88,6 +88,9 @@ const client = { patch: (endpoint: string, body: unknown) => request(endpoint, { method: "PATCH", body }), + put: (endpoint: string, body: unknown) => + request(endpoint, { method: "PUT", body }), + getRepo: () => config.getRepo(), isOk: (status: number) => isSuccessful(status), isNotFound: (status: number) => status === STATUS_NOT_FOUND, diff --git a/src/api/notifications.ts b/src/api/notifications.ts new file mode 100644 index 0000000..059d284 --- /dev/null +++ b/src/api/notifications.ts @@ -0,0 +1,50 @@ +import client from "./client"; + +const BASE_PATH = "/notifications"; + +const notifications = { + fetch: (params?: { + all?: boolean; + participating?: boolean; + perPage?: number; + }): Promise => { + const query = new URLSearchParams(); + if (params?.all) query.set("all", "true"); + if (params?.participating) query.set("participating", "true"); + if (params?.perPage) query.set("per_page", String(params.perPage)); + + const qs = query.toString(); + const endpoint = qs ? `${BASE_PATH}?${qs}` : BASE_PATH; + return client.get(endpoint); + }, + + markRead: (id: string): Promise => { + return client.patch(`/notifications/threads/${id}`, {}); + }, + + markDone: (id: string): Promise => { + return client.put(`/notifications/threads/${id}/subscription`, { + ignored: true, + }); + }, + + assignedIssues: (): Promise => { + return client.get("/issues?filter=assigned&state=open"); + }, + + reviewRequests: (): Promise => { + return client.get("/search/issues?q=is:pr+is:open+review-requested:@me"); + }, + + mentions: (username: string): Promise => { + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0]; + + return client.get( + `/search/issues?q=mentions:${username}+updated:>${since}`, + ); + }, +}; + +export default notifications; diff --git a/src/cli/index.ts b/src/cli/index.ts index 213245f..e807beb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,9 +3,13 @@ import { program } from "commander"; import ascii from "./ascii"; import logger from "@/core/logger"; +import ghCommand from "@/commands/gh"; import pingCommand from "@/commands/ping"; import labelsCommand from "@/commands/labels"; import configCommand from "@/commands/config"; +import mentionsCommand from "@/commands/mentions"; +import activityCommand from "@/commands/activity"; +import notificationsCommand from "@/commands/notifications"; import { GhitgudError } from "@/core/errors"; const NAME = "ghitgud"; @@ -13,6 +17,10 @@ const DESCRIPTION = "A simple CLI to give superpowers to GitHub."; program.name(NAME).description(DESCRIPTION).version(__VERSION__); +ghCommand.register(program); +notificationsCommand.register(program); +activityCommand.register(program); +mentionsCommand.register(program); pingCommand.register(program); labelsCommand.register(program); configCommand.register(program); diff --git a/src/commands/activity.ts b/src/commands/activity.ts new file mode 100644 index 0000000..2ad1f82 --- /dev/null +++ b/src/commands/activity.ts @@ -0,0 +1,11 @@ +import { Command } from "commander"; +import service from "@/services/notifications"; + +const register = (program: Command) => { + program + .command("activity") + .description("Show assigned issues, review requests, and mentions.") + .action(() => void service.activity()); +}; + +export default { register }; diff --git a/src/commands/gh.ts b/src/commands/gh.ts new file mode 100644 index 0000000..60490df --- /dev/null +++ b/src/commands/gh.ts @@ -0,0 +1,40 @@ +import process from "process"; +import { spawn } from "child_process"; +import { Command } from "commander"; + +import logger from "@/core/logger"; + +const register = (program: Command) => { + program + .command("gh") + .description("Pass through to the gh CLI. Usage: ghitgud gh ") + .allowUnknownOption() + .action((_opts, command) => { + const args = command.args; + + const child = spawn("gh", args, { + stdio: "inherit", + shell: false, + }); + + child.on("error", (error: { code?: string }) => { + if (error.code === "ENOENT") { + logger.error( + "gh CLI is not installed. " + + "Install it from https://cli.github.com.", + ); + + process.exit(1); + } + + logger.error(String(error)); + process.exit(1); + }); + + child.on("exit", (code) => { + process.exitCode = code ?? 0; + }); + }); +}; + +export default { register }; diff --git a/src/commands/mentions.ts b/src/commands/mentions.ts new file mode 100644 index 0000000..76d0f4a --- /dev/null +++ b/src/commands/mentions.ts @@ -0,0 +1,11 @@ +import { Command } from "commander"; +import service from "@/services/notifications"; + +const register = (program: Command) => { + program + .command("mentions") + .description("Find recent @mentions of you.") + .action(() => void service.mentions()); +}; + +export default { register }; diff --git a/src/commands/notifications.ts b/src/commands/notifications.ts new file mode 100644 index 0000000..8fd4bca --- /dev/null +++ b/src/commands/notifications.ts @@ -0,0 +1,40 @@ +import { Command } from "commander"; +import service from "@/services/notifications"; + +const register = (program: Command) => { + const notifications = program + .command("notifications") + .description("Manage GitHub notifications."); + + notifications + .command("list") + .description("List notifications.") + .option("-a, --all", "Include read notifications") + .option("-p, --participating", "Only participating notifications") + .option("-r, --repo ", "Filter by repository") + .option("-l, --limit ", "Max results") + .action((options) => { + void service.list({ + all: options.all, + participating: options.participating, + repo: options.repo, + limit: options.limit ? parseInt(options.limit, 10) : undefined, + }); + }); + + notifications + .command("read ") + .description("Mark a notification as read.") + .action((id: string) => { + void service.markRead(id); + }); + + notifications + .command("done ") + .description("Mark a notification as done.") + .action((id: string) => { + void service.markDone(id); + }); +}; + +export default { register }; diff --git a/src/core/constants.ts b/src/core/constants.ts index d1d6d33..2f4a4d6 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -30,6 +30,7 @@ export const ERROR_NO_TOKEN = "Token not configured. Set it with: ghitgud config set token ."; export const ERROR_UNSUPPORTED_KEY = "Trying to set unsupported key."; export const ERROR_NO_METADATA = "No metadata file found."; +export const INFO_NO_NOTIFICATIONS = "No notifications found."; export const PING_RESPONSE = "pong"; diff --git a/src/services/notifications.ts b/src/services/notifications.ts new file mode 100644 index 0000000..8cf42c7 --- /dev/null +++ b/src/services/notifications.ts @@ -0,0 +1,106 @@ +import api from "@/api/notifications"; +import logger from "@/core/logger"; +import { INFO_NO_NOTIFICATIONS } from "@/core/constants"; +import { + Notification, + ActivityResult, + ListOptions, + normalizeThread, + normalizeIssue, + normalizeSearchItem, +} from "@/types/notifications"; + +const formatTable = (notifications: Notification[]) => { + if (notifications.length === 0) { + logger.info(INFO_NO_NOTIFICATIONS); + return; + } + + console.log(); + console.table( + notifications.map((n) => ({ + repository: n.repository, + subject: n.subjectTitle, + type: n.subjectType, + reason: n.reason, + })), + ); +}; + +const list = async (options: ListOptions = {}) => { + logger.info("Fetching notifications."); + + const response = await api.fetch({ + all: options.all, + participating: options.participating, + perPage: options.limit, + }); + + const data = (await response.json()) as unknown[]; + let notifications = data.map(normalizeThread); + + if (options.repo) { + notifications = notifications.filter((n) => n.repository === options.repo); + } + + formatTable(notifications); + return { success: true, metadata: notifications }; +}; + +const markRead = async (id: string) => { + logger.info(`Marking notification ${id} as read.`); + await api.markRead(id); + logger.success("Notification marked as read."); + return { success: true }; +}; + +const markDone = async (id: string) => { + logger.info(`Marking notification ${id} as done.`); + await api.markDone(id); + logger.success("Notification marked as done."); + return { success: true }; +}; + +const activity = async () => { + logger.info("Fetching activity."); + + const [issuesRes, reviewsRes, mentionsRes] = await Promise.all([ + api.assignedIssues(), + api.reviewRequests(), + api.mentions("@me"), + ]); + + const assignedIssues = (await issuesRes.json()) as unknown[]; + const reviewData = (await reviewsRes.json()) as { + items?: unknown[]; + }; + const mentionData = (await mentionsRes.json()) as { + items?: unknown[]; + }; + + const result: ActivityResult = { + assignedIssues: assignedIssues.map(normalizeIssue), + reviewRequests: (reviewData.items ?? []).map(normalizeSearchItem), + recentMentions: (mentionData.items ?? []).map(normalizeSearchItem), + }; + + console.log(); + console.log("Assigned Issues:", result.assignedIssues.length); + console.log("Review Requests:", result.reviewRequests.length); + console.log("Recent Mentions:", result.recentMentions.length); + + return { success: true, metadata: result }; +}; + +const mentions = async () => { + logger.info("Fetching mentions."); + + const response = await api.mentions("@me"); + const data = (await response.json()) as { items?: unknown[] }; + const notifications = (data.items ?? []).map(normalizeSearchItem); + + formatTable(notifications); + return { success: true, metadata: notifications }; +}; + +export default { list, markRead, markDone, activity, mentions }; diff --git a/src/types/notifications.ts b/src/types/notifications.ts new file mode 100644 index 0000000..9f22901 --- /dev/null +++ b/src/types/notifications.ts @@ -0,0 +1,70 @@ +export interface Notification { + id: string; + repository: string; + subjectTitle: string; + subjectType: string; + reason: string; + unread: boolean; + updatedAt: string; +} + +export interface ActivityResult { + assignedIssues: Notification[]; + reviewRequests: Notification[]; + recentMentions: Notification[]; +} + +export interface ListOptions { + all?: boolean; + participating?: boolean; + repo?: string; + limit?: number; +} + +export const normalizeThread = (item: unknown): Notification => { + const data = item as Record; + const repo = (data.repository ?? {}) as Record; + const subject = (data.subject ?? {}) as Record; + + return { + id: String(data.id), + repository: String(repo.full_name ?? ""), + subjectTitle: String(subject.title ?? ""), + subjectType: String(subject.type ?? ""), + reason: String(data.reason ?? ""), + unread: Boolean(data.unread), + updatedAt: String(data.updated_at ?? ""), + }; +}; + +export const normalizeIssue = (item: unknown): Notification => { + const data = item as Record; + const repo = (data.repository ?? {}) as Record; + + return { + id: String(data.id), + repository: String(repo.full_name ?? ""), + subjectTitle: String(data.title ?? ""), + subjectType: String(data.pull_request ? "PullRequest" : "Issue"), + reason: "assigned", + unread: false, + updatedAt: String(data.updated_at ?? ""), + }; +}; + +export const normalizeSearchItem = (item: unknown): Notification => { + const data = item as Record; + + return { + id: String(data.id), + repository: String(data.repository_url ?? "").replace( + "https://api.github.com/repos/", + "", + ), + subjectTitle: String(data.title ?? ""), + subjectType: String(data.pull_request ? "PullRequest" : "Issue"), + reason: "mention", + unread: false, + updatedAt: String(data.updated_at ?? ""), + }; +}; diff --git a/tests/unit/api/client.test.ts b/tests/unit/api/client.test.ts index d4d62ae..63c2895 100644 --- a/tests/unit/api/client.test.ts +++ b/tests/unit/api/client.test.ts @@ -76,6 +76,21 @@ describe("client", () => { ); }); + it("should make a PUT request", async () => { + (global.fetch as ReturnType).mockResolvedValue({ + status: 200, + }); + + await client.put("/notifications/threads/123/subscription", { + ignored: true, + }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/notifications/threads/123/subscription", + expect.objectContaining({ method: "PUT" }), + ); + }); + it("should throw AuthError on 401", async () => { (global.fetch as ReturnType).mockResolvedValue({ status: 401, diff --git a/tests/unit/api/notifications.test.ts b/tests/unit/api/notifications.test.ts new file mode 100644 index 0000000..c2804da --- /dev/null +++ b/tests/unit/api/notifications.test.ts @@ -0,0 +1,73 @@ +import client from "@/api/client"; +import notifications from "@/api/notifications"; +import { describe, it, expect, vi, Mock, beforeEach } from "vitest"; + +vi.mock("@/api/client", () => ({ + default: { + get: vi.fn(), + patch: vi.fn(), + put: vi.fn(), + }, +})); + +describe("notifications api", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call client.get for fetch", async () => { + (client.get as Mock).mockResolvedValue({ status: 200 }); + await notifications.fetch(); + expect(client.get).toHaveBeenCalledWith("/notifications"); + }); + + it("should call client.get with query params", async () => { + (client.get as Mock).mockResolvedValue({ status: 200 }); + await notifications.fetch({ all: true, participating: true, perPage: 50 }); + + expect(client.get).toHaveBeenCalledWith( + "/notifications?all=true&participating=true&per_page=50", + ); + }); + + it("should call client.patch for markRead", async () => { + (client.patch as Mock).mockResolvedValue({ status: 205 }); + await notifications.markRead("123"); + expect(client.patch).toHaveBeenCalledWith("/notifications/threads/123", {}); + }); + + it("should call client.put for markDone", async () => { + (client.put as Mock).mockResolvedValue({ status: 200 }); + await notifications.markDone("123"); + + expect(client.put).toHaveBeenCalledWith( + "/notifications/threads/123/subscription", + { ignored: true }, + ); + }); + + it("should call client.get for assignedIssues", async () => { + (client.get as Mock).mockResolvedValue({ status: 200 }); + await notifications.assignedIssues(); + + expect(client.get).toHaveBeenCalledWith( + "/issues?filter=assigned&state=open", + ); + }); + + it("should call client.get for reviewRequests", async () => { + (client.get as Mock).mockResolvedValue({ status: 200 }); + await notifications.reviewRequests(); + + expect(client.get).toHaveBeenCalledWith( + "/search/issues?q=is:pr+is:open+review-requested:@me", + ); + }); + + it("should call client.get for mentions with date filter", async () => { + (client.get as Mock).mockResolvedValue({ status: 200 }); + await notifications.mentions("@me"); + const call = (client.get as Mock).mock.calls[0][0] as string; + expect(call).toContain("/search/issues?q=mentions:@me+updated:>"); + }); +}); diff --git a/tests/unit/commands/activity.test.ts b/tests/unit/commands/activity.test.ts new file mode 100644 index 0000000..22279b7 --- /dev/null +++ b/tests/unit/commands/activity.test.ts @@ -0,0 +1,12 @@ +import { Command } from "commander"; +import { describe, it, expect } from "vitest"; +import activityCommand from "@/commands/activity"; + +describe("activity command", () => { + it("should register activity command on program", () => { + const program = new Command(); + activityCommand.register(program); + const commands = program.commands.map((c) => c.name()); + expect(commands).toContain("activity"); + }); +}); diff --git a/tests/unit/commands/gh.test.ts b/tests/unit/commands/gh.test.ts new file mode 100644 index 0000000..dbfa8c1 --- /dev/null +++ b/tests/unit/commands/gh.test.ts @@ -0,0 +1,12 @@ +import { Command } from "commander"; +import { describe, it, expect } from "vitest"; +import ghCommand from "@/commands/gh"; + +describe("gh command", () => { + it("should register gh command on program", () => { + const program = new Command(); + ghCommand.register(program); + const commands = program.commands.map((c) => c.name()); + expect(commands).toContain("gh"); + }); +}); diff --git a/tests/unit/commands/mentions.test.ts b/tests/unit/commands/mentions.test.ts new file mode 100644 index 0000000..54faf9a --- /dev/null +++ b/tests/unit/commands/mentions.test.ts @@ -0,0 +1,12 @@ +import { Command } from "commander"; +import { describe, it, expect } from "vitest"; +import mentionsCommand from "@/commands/mentions"; + +describe("mentions command", () => { + it("should register mentions command on program", () => { + const program = new Command(); + mentionsCommand.register(program); + const commands = program.commands.map((c) => c.name()); + expect(commands).toContain("mentions"); + }); +}); diff --git a/tests/unit/commands/notifications.test.ts b/tests/unit/commands/notifications.test.ts new file mode 100644 index 0000000..c201c43 --- /dev/null +++ b/tests/unit/commands/notifications.test.ts @@ -0,0 +1,20 @@ +import { Command } from "commander"; +import { describe, it, expect } from "vitest"; +import notificationsCommand from "@/commands/notifications"; + +describe("notifications command", () => { + it("should register notifications with subcommands", () => { + const program = new Command(); + notificationsCommand.register(program); + + const notifications = program.commands.find( + (c) => c.name() === "notifications", + ); + + expect(notifications).toBeDefined(); + const subcommands = notifications!.commands.map((c) => c.name()); + expect(subcommands).toContain("list"); + expect(subcommands).toContain("read"); + expect(subcommands).toContain("done"); + }); +}); diff --git a/tests/unit/services/notifications.test.ts b/tests/unit/services/notifications.test.ts new file mode 100644 index 0000000..d8f4ede --- /dev/null +++ b/tests/unit/services/notifications.test.ts @@ -0,0 +1,133 @@ +import api from "@/api/notifications"; +import logger from "@/core/logger"; +import service from "@/services/notifications"; +import { describe, it, expect, vi, Mock, beforeEach } from "vitest"; + +vi.mock("@/api/notifications", () => ({ + default: { + fetch: vi.fn(), + markRead: vi.fn(), + markDone: vi.fn(), + assignedIssues: vi.fn(), + reviewRequests: vi.fn(), + mentions: vi.fn(), + }, +})); + +vi.mock("@/core/logger", () => ({ + default: { + info: vi.fn(), + success: vi.fn(), + }, +})); + +const THREAD_RESPONSE = [ + { + id: "1", + unread: true, + reason: "review_requested", + updated_at: "2026-05-09T20:00:00Z", + repository: { full_name: "airscripts/ghitgud" }, + subject: { title: "Test PR", type: "PullRequest", url: "..." }, + }, +]; + +const SEARCH_RESPONSE = { + items: [ + { + id: 2, + title: "Mentioned issue", + updated_at: "2026-05-08T20:00:00Z", + repository_url: "https://api.github.com/repos/airscripts/ghitgud", + }, + ], +}; + +describe("notifications service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("list", () => { + it("should return notifications", async () => { + (api.fetch as Mock).mockResolvedValue({ + json: () => Promise.resolve(THREAD_RESPONSE), + }); + + const result = await service.list(); + expect(result.success).toBe(true); + expect(result.metadata).toHaveLength(1); + expect(result.metadata[0].repository).toBe("airscripts/ghitgud"); + }); + + it("should filter by repo", async () => { + (api.fetch as Mock).mockResolvedValue({ + json: () => Promise.resolve(THREAD_RESPONSE), + }); + + const result = await service.list({ repo: "other/repo" }); + expect(result.metadata).toHaveLength(0); + expect(logger.info).toHaveBeenCalledWith("No notifications found."); + }); + + it("should show info when no notifications", async () => { + (api.fetch as Mock).mockResolvedValue({ + json: () => Promise.resolve([]), + }); + + const result = await service.list(); + expect(result.metadata).toHaveLength(0); + expect(logger.info).toHaveBeenCalledWith("No notifications found."); + }); + }); + + describe("markRead", () => { + it("should mark notification as read", async () => { + (api.markRead as Mock).mockResolvedValue({ status: 205 }); + const result = await service.markRead("1"); + expect(result.success).toBe(true); + }); + }); + + describe("markDone", () => { + it("should mark notification as done", async () => { + (api.markDone as Mock).mockResolvedValue({ status: 200 }); + const result = await service.markDone("1"); + expect(result.success).toBe(true); + }); + }); + + describe("activity", () => { + it("should return composite activity", async () => { + (api.assignedIssues as Mock).mockResolvedValue({ + json: () => Promise.resolve([]), + }); + + (api.reviewRequests as Mock).mockResolvedValue({ + json: () => Promise.resolve(SEARCH_RESPONSE), + }); + + (api.mentions as Mock).mockResolvedValue({ + json: () => Promise.resolve(SEARCH_RESPONSE), + }); + + const result = await service.activity(); + expect(result.success).toBe(true); + expect(result.metadata.assignedIssues).toHaveLength(0); + expect(result.metadata.reviewRequests).toHaveLength(1); + expect(result.metadata.recentMentions).toHaveLength(1); + }); + }); + + describe("mentions", () => { + it("should return mentions", async () => { + (api.mentions as Mock).mockResolvedValue({ + json: () => Promise.resolve(SEARCH_RESPONSE), + }); + + const result = await service.mentions(); + expect(result.success).toBe(true); + expect(result.metadata).toHaveLength(1); + }); + }); +});