From 783c1bc1ac9c820576b84a0eb562b5c3de138844 Mon Sep 17 00:00:00 2001 From: Seiya Kojima <20368071+seiya-koji@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:11:44 +0900 Subject: [PATCH] feat: add commands to open webhook settings on GitHub Provide two ways to jump to GitHub's webhook settings from the tree view: - An inline gear icon on each webhook opens that hook's settings page (.../settings/hooks/{id}). - A toolbar button opens the repository's webhooks list page (.../settings/hooks). URLs are built by a new webhookSettingsUrl() helper that reuses the resolved RepoRef (host/owner/repo), so GitHub Enterprise hosts work too. --- __mocks__/vscode.ts | 4 +++ package.json | 28 +++++++++++++++++ src/extension.ts | 29 ++++++++++++++++-- src/github/repoResolver.ts | 10 ++++++ tests/extension.test.ts | 51 +++++++++++++++++++++++++++++-- tests/github/repoResolver.test.ts | 20 +++++++++++- 6 files changed, 136 insertions(+), 6 deletions(-) diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts index 8bc0758..33b8ea6 100644 --- a/__mocks__/vscode.ts +++ b/__mocks__/vscode.ts @@ -110,3 +110,7 @@ export const Uri = { file: vi.fn((path: string) => ({ fsPath: path })), parse: vi.fn((value: string) => ({ toString: () => value })), }; + +export const env = { + openExternal: vi.fn(() => Promise.resolve(true)), +}; diff --git a/package.json b/package.json index f943ce3..3a2c00e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,18 @@ "title": "Redeliver", "category": "GitHub Webhooks", "icon": "$(history)" + }, + { + "command": "githubWebhooks.openWebhookSettings", + "title": "Open Webhook Settings on GitHub", + "category": "GitHub Webhooks", + "icon": "$(gear)" + }, + { + "command": "githubWebhooks.openRepoWebhooks", + "title": "Open Repository Webhooks Page on GitHub", + "category": "GitHub Webhooks", + "icon": "$(github-inverted)" } ], "menus": { @@ -70,6 +82,11 @@ "command": "githubWebhooks.refresh", "when": "view == githubWebhooks.deliveries", "group": "navigation@1" + }, + { + "command": "githubWebhooks.openRepoWebhooks", + "when": "view == githubWebhooks.deliveries", + "group": "navigation@2" } ], "view/item/context": [ @@ -82,6 +99,17 @@ "command": "githubWebhooks.showDelivery", "when": "view == githubWebhooks.deliveries && viewItem == delivery", "group": "1_actions" + }, + { + "command": "githubWebhooks.openWebhookSettings", + "when": "view == githubWebhooks.deliveries && viewItem == webhook", + "group": "inline" + } + ], + "commandPalette": [ + { + "command": "githubWebhooks.openWebhookSettings", + "when": "false" } ] } diff --git a/src/extension.ts b/src/extension.ts index b7390ea..cce47e6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,9 +1,9 @@ import * as vscode from 'vscode'; import { getOctokit } from './auth'; -import { resolveRepo, type RepoRef } from './github/repoResolver'; +import { resolveRepo, webhookSettingsUrl, type RepoRef } from './github/repoResolver'; import { WebhookClient } from './github/webhookClient'; import { WebhookTreeProvider } from './tree/webhookTreeProvider'; -import { DeliveryNode } from './tree/nodes'; +import { DeliveryNode, WebhookNode } from './tree/nodes'; import { DeliveryPanel } from './panel/deliveryPanel'; import { toMessage } from './errors'; @@ -81,6 +81,25 @@ export function activate(context: vscode.ExtensionContext): void { } } + async function openWebhookSettings(node: WebhookNode): Promise { + if (!currentRepo || !node?.webhook) { + return; + } + await vscode.env.openExternal( + vscode.Uri.parse(webhookSettingsUrl(currentRepo, node.webhook.id)) + ); + } + + async function openRepoWebhooks(): Promise { + if (!currentRepo) { + void vscode.window.showWarningMessage( + 'GitHub Webhook Manager: No GitHub repository detected in this workspace.' + ); + return; + } + await vscode.env.openExternal(vscode.Uri.parse(webhookSettingsUrl(currentRepo))); + } + context.subscriptions.push( vscode.commands.registerCommand('githubWebhooks.refresh', () => provider.refresh()), vscode.commands.registerCommand('githubWebhooks.showDelivery', (node: DeliveryNode) => @@ -88,7 +107,11 @@ export function activate(context: vscode.ExtensionContext): void { ), vscode.commands.registerCommand('githubWebhooks.redeliver', (node: DeliveryNode) => redeliver(node) - ) + ), + vscode.commands.registerCommand('githubWebhooks.openWebhookSettings', (node: WebhookNode) => + openWebhookSettings(node) + ), + vscode.commands.registerCommand('githubWebhooks.openRepoWebhooks', () => openRepoWebhooks()) ); void load(); diff --git a/src/github/repoResolver.ts b/src/github/repoResolver.ts index fd95861..bcfea7d 100644 --- a/src/github/repoResolver.ts +++ b/src/github/repoResolver.ts @@ -9,6 +9,16 @@ export interface RepoRef { export const GITHUB_COM = 'github.com'; +/** + * Build the browser URL for a repository's webhook settings page. + * With a hookId, points at that single webhook; without it, the hooks list. + * Works for github.com and GitHub Enterprise Server (the host is taken from repo). + */ +export function webhookSettingsUrl(repo: RepoRef, hookId?: number): string { + const base = `https://${repo.host}/${repo.owner}/${repo.repo}/settings/hooks`; + return hookId === undefined ? base : `${base}/${hookId}`; +} + /** * Parse a git remote URL (https, ssh://, or scp-style) into a RepoRef. * The host is extracted from the URL and included in the result, so both diff --git a/tests/extension.test.ts b/tests/extension.test.ts index 79682d5..5895a37 100644 --- a/tests/extension.test.ts +++ b/tests/extension.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import * as vscode from 'vscode'; import { activate, deactivate } from '../src/extension'; -import { DeliveryNode, MessageNode } from '../src/tree/nodes'; -import type { DeliverySummary } from '../src/github/webhookClient'; +import { DeliveryNode, MessageNode, WebhookNode } from '../src/tree/nodes'; +import type { DeliverySummary, Webhook } from '../src/github/webhookClient'; const repos = vi.hoisted(() => ({ listWebhooks: vi.fn(), @@ -27,6 +27,8 @@ const COMMANDS = [ 'githubWebhooks.refresh', 'githubWebhooks.showDelivery', 'githubWebhooks.redeliver', + 'githubWebhooks.openWebhookSettings', + 'githubWebhooks.openRepoWebhooks', ]; const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -67,6 +69,14 @@ const deliveryNode = new DeliveryNode(7, { duration: 1, } as unknown as DeliverySummary); +const webhookNode = new WebhookNode({ + id: 42, + name: 'web', + active: true, + events: ['push'], + config: {}, +} as unknown as Webhook); + describe('activate', () => { beforeEach(() => { vi.clearAllMocks(); @@ -137,6 +147,43 @@ describe('activate', () => { expect(roots[0]).toBeInstanceOf(MessageNode); }); + it('opens the repository webhooks page in the browser', async () => { + activate(makeContext()); + await flush(); + + await handlerFor('githubWebhooks.openRepoWebhooks')(); + + expect(vscode.Uri.parse).toHaveBeenCalledWith( + 'https://github.com/seiya-koji/demo/settings/hooks' + ); + expect(vscode.env.openExternal).toHaveBeenCalled(); + }); + + it('opens a single webhook settings page in the browser', async () => { + activate(makeContext()); + await flush(); + + await handlerFor('githubWebhooks.openWebhookSettings')(webhookNode); + + expect(vscode.Uri.parse).toHaveBeenCalledWith( + 'https://github.com/seiya-koji/demo/settings/hooks/42' + ); + expect(vscode.env.openExternal).toHaveBeenCalled(); + }); + + it('warns instead of opening when no repository is detected', async () => { + execFile.mockImplementation((_c: string, _a: string[], _o: unknown, cb: ExecCb) => + cb(new Error('not a git repository'), '') + ); + activate(makeContext()); + await flush(); + + await handlerFor('githubWebhooks.openRepoWebhooks')(); + + expect(vscode.env.openExternal).not.toHaveBeenCalled(); + expect(vscode.window.showWarningMessage).toHaveBeenCalled(); + }); + it('deactivate runs without throwing', () => { expect(() => deactivate()).not.toThrow(); }); diff --git a/tests/github/repoResolver.test.ts b/tests/github/repoResolver.test.ts index f136630..00aa161 100644 --- a/tests/github/repoResolver.test.ts +++ b/tests/github/repoResolver.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import * as vscode from 'vscode'; -import { parseGitHubRemote, resolveRepo } from '../../src/github/repoResolver'; +import { parseGitHubRemote, resolveRepo, webhookSettingsUrl } from '../../src/github/repoResolver'; const execFile = vi.hoisted(() => vi.fn()); vi.mock('node:child_process', () => ({ execFile })); @@ -133,3 +133,21 @@ describe('resolveRepo', () => { expect(execFile).not.toHaveBeenCalled(); }); }); + +describe('webhookSettingsUrl', () => { + const repo = { host: 'github.com', owner: 'owner', repo: 'repo' }; + + it('builds the single-webhook settings URL when a hookId is given', () => { + expect(webhookSettingsUrl(repo, 123)).toBe('https://github.com/owner/repo/settings/hooks/123'); + }); + + it('builds the hooks-list URL when no hookId is given', () => { + expect(webhookSettingsUrl(repo)).toBe('https://github.com/owner/repo/settings/hooks'); + }); + + it('uses the host from the repo for GitHub Enterprise Server', () => { + expect(webhookSettingsUrl({ host: 'github.example.com', owner: 'org', repo: 'app' }, 7)).toBe( + 'https://github.example.com/org/app/settings/hooks/7' + ); + }); +});