From 537eab478393e1235a0f8004b427a15c71e8e38e Mon Sep 17 00:00:00 2001 From: kry-kylewhittle Date: Thu, 11 Jun 2026 10:25:55 +0100 Subject: [PATCH] Initial scaffold: Obsidian GitHub Wiki Tools plugin --- .gitignore | 3 + esbuild.config.mjs | 39 ++++ main.ts | 481 +++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 10 + package.json | 21 ++ styles.css | 128 ++++++++++++ tsconfig.json | 16 ++ 7 files changed, 698 insertions(+) create mode 100644 .gitignore create mode 100644 esbuild.config.mjs create mode 100644 main.ts create mode 100644 manifest.json create mode 100644 package.json create mode 100644 styles.css create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ad3282 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +main.js +*.js.map diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..7c7386f --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,39 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + entryPoints: ["main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..9c5e3f0 --- /dev/null +++ b/main.ts @@ -0,0 +1,481 @@ +import { + App, + ItemView, + Notice, + Plugin, + PluginSettingTab, + Setting, + WorkspaceLeaf, +} from "obsidian"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); + +const VIEW_TYPE = "github-wiki-sidebar"; + +// ── Settings ──────────────────────────────────────────────────────────────── + +interface Settings { + repoPath: string; + githubToken: string; + repoOwner: string; + repoName: string; + githubUsername: string; + pollIntervalMinutes: number; + // PR number → last-seen updatedAt timestamp (ISO string) + seenPRActivity: Record; +} + +const DEFAULT_SETTINGS: Settings = { + repoPath: "", + githubToken: "", + repoOwner: "", + repoName: "", + githubUsername: "", + pollIntervalMinutes: 15, + seenPRActivity: {}, +}; + +// ── Git service ────────────────────────────────────────────────────────────── + +interface GitStatus { + branch: string; + hasUncommittedChanges: boolean; + commitsBehind: number; +} + +async function gitExec(repoPath: string, args: string): Promise { + const { stdout } = await execAsync(`git -C "${repoPath}" ${args}`); + return stdout.trim(); +} + +async function fetchGitStatus(repoPath: string): Promise { + await gitExec(repoPath, "fetch origin"); + + const branch = await gitExec(repoPath, "branch --show-current"); + const porcelain = await gitExec(repoPath, "status --porcelain"); + const hasUncommittedChanges = porcelain.length > 0; + + const behindStr = await gitExec( + repoPath, + `rev-list --count HEAD..origin/${branch}` + ); + const commitsBehind = parseInt(behindStr, 10) || 0; + + return { branch, hasUncommittedChanges, commitsBehind }; +} + +async function runGitPull(repoPath: string): Promise { + await gitExec(repoPath, "pull"); +} + +// ── GitHub service ─────────────────────────────────────────────────────────── + +interface PullRequest { + number: number; + title: string; + url: string; + author: string; + updatedAt: string; + draft: boolean; +} + +async function fetchOpenPRs( + owner: string, + repo: string, + token: string +): Promise { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls?state=open&per_page=50`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + } + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + + const data: any[] = await response.json(); + + return data.map((pr) => ({ + number: pr.number, + title: pr.title, + url: pr.html_url, + author: pr.user.login, + updatedAt: pr.updated_at, + draft: pr.draft ?? false, + })); +} + +// ── Sidebar view ───────────────────────────────────────────────────────────── + +class WikiSidebarView extends ItemView { + private plugin: GitHubWikiPlugin; + private gitStatus: GitStatus | null = null; + private prs: PullRequest[] = []; + private loading = false; + private errorMsg: string | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: GitHubWikiPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { return VIEW_TYPE; } + getDisplayText(): string { return "GitHub Wiki"; } + getIcon(): string { return "github"; } + + async onOpen(): Promise { + await this.refresh(); + } + + async refresh(): Promise { + this.loading = true; + this.errorMsg = null; + this.render(); + + try { + const s = this.plugin.settings; + + if (s.repoPath) { + this.gitStatus = await fetchGitStatus(s.repoPath); + } + + if (s.githubToken && s.repoOwner && s.repoName) { + this.prs = await fetchOpenPRs(s.repoOwner, s.repoName, s.githubToken); + this.plugin.initSeenActivity(this.prs); + } + } catch (e: any) { + this.errorMsg = e.message; + } finally { + this.loading = false; + this.render(); + } + } + + render(): void { + const root = this.containerEl.children[1] as HTMLElement; + root.empty(); + root.addClass("gwt-sidebar"); + + const header = root.createDiv("gwt-header"); + header.createEl("h4", { text: "GitHub Wiki" }); + const refreshBtn = header.createEl("button", { cls: "gwt-btn-icon", text: "↻" }); + refreshBtn.setAttribute("aria-label", "Refresh"); + refreshBtn.addEventListener("click", () => this.refresh()); + + if (this.loading) { + root.createDiv("gwt-muted").setText("Loading…"); + return; + } + + if (this.errorMsg) { + root.createDiv("gwt-error").setText(this.errorMsg); + } + + this.renderGitStatus(root); + this.renderPRs(root); + } + + private renderGitStatus(root: HTMLElement): void { + const s = this.gitStatus; + if (!s) { + if (!this.plugin.settings.repoPath) { + root.createDiv("gwt-muted").setText("Set repo path in settings to see git status."); + } + return; + } + + const section = root.createDiv("gwt-section"); + section.createEl("h5", { text: "Local Repo" }); + + const branchRow = section.createDiv("gwt-row"); + branchRow.createSpan({ cls: "gwt-label", text: "Branch: " }); + branchRow.createSpan({ text: s.branch }); + + if (s.hasUncommittedChanges) { + section.createDiv("gwt-row gwt-warning").setText("⚠ Uncommitted changes"); + } + + if (s.commitsBehind > 0) { + const behindRow = section.createDiv("gwt-row gwt-behind"); + behindRow.createSpan({ + text: `⬇ ${s.commitsBehind} commit${s.commitsBehind !== 1 ? "s" : ""} behind origin/${s.branch}`, + }); + const pullBtn = behindRow.createEl("button", { cls: "gwt-btn", text: "Pull" }); + pullBtn.addEventListener("click", () => this.handlePull()); + } else { + section.createDiv("gwt-row gwt-ok").setText("✓ Up to date"); + } + } + + private renderPRs(root: HTMLElement): void { + const s = this.plugin.settings; + if (!s.githubToken || !s.repoOwner || !s.repoName) { + root.createDiv("gwt-muted").setText("Configure GitHub settings to see PRs."); + return; + } + + const myPRs = this.prs.filter((pr) => pr.author === s.githubUsername); + const otherPRs = this.prs.filter((pr) => pr.author !== s.githubUsername); + + const mySection = root.createDiv("gwt-section"); + mySection.createEl("h5", { text: `My PRs (${myPRs.length})` }); + if (myPRs.length === 0) { + mySection.createDiv("gwt-empty").setText("No open PRs"); + } else { + myPRs.forEach((pr) => this.renderPR(mySection, pr)); + } + + const allSection = root.createDiv("gwt-section"); + allSection.createEl("h5", { text: `All Open PRs (${otherPRs.length})` }); + if (otherPRs.length === 0) { + allSection.createDiv("gwt-empty").setText("No other open PRs"); + } else { + otherPRs.forEach((pr) => this.renderPR(allSection, pr)); + } + } + + private renderPR(container: HTMLElement, pr: PullRequest): void { + const hasNew = this.plugin.hasNewActivity(pr); + const row = container.createDiv(hasNew ? "gwt-pr gwt-pr-new" : "gwt-pr"); + + const link = row.createEl("a", { + cls: "gwt-pr-link", + href: pr.url, + text: `#${pr.number} ${pr.title}`, + }); + link.addEventListener("click", (e) => { + e.preventDefault(); + window.open(pr.url, "_blank"); + this.plugin.markSeen(pr); + row.removeClass("gwt-pr-new"); + row.querySelector(".gwt-new-badge")?.remove(); + }); + + const meta = row.createDiv("gwt-pr-meta"); + meta.createSpan({ text: `@${pr.author}` }); + if (pr.draft) meta.createSpan({ cls: "gwt-draft", text: " · Draft" }); + if (hasNew) meta.createSpan({ cls: "gwt-new-badge", text: " · New activity" }); + } + + private async handlePull(): Promise { + const { gitStatus } = this; + if (!gitStatus) return; + + if (gitStatus.hasUncommittedChanges) { + new Notice( + "Cannot pull: you have uncommitted changes. Commit or stash them first.", + 8000 + ); + return; + } + + try { + await runGitPull(this.plugin.settings.repoPath); + new Notice("Pull successful."); + await this.refresh(); + } catch (e: any) { + new Notice(`Pull failed: ${e.message}`, 8000); + } + } +} + +// ── Plugin ─────────────────────────────────────────────────────────────────── + +export default class GitHubWikiPlugin extends Plugin { + settings: Settings; + private pollTimer: number | null = null; + + async onload(): Promise { + await this.loadSettings(); + + this.registerView(VIEW_TYPE, (leaf) => new WikiSidebarView(leaf, this)); + + this.addRibbonIcon("github", "GitHub Wiki", () => this.activateView()); + + this.addSettingTab(new GitHubWikiSettingTab(this.app, this)); + + this.app.workspace.onLayoutReady(() => { + this.activateView(); + this.startPolling(); + }); + } + + onunload(): void { + this.stopPolling(); + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + if (!this.settings.seenPRActivity) this.settings.seenPRActivity = {}; + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + async activateView(): Promise { + const { workspace } = this.app; + let leaf = workspace.getLeavesOfType(VIEW_TYPE)[0]; + if (!leaf) { + leaf = workspace.getRightLeaf(false)!; + await leaf.setViewState({ type: VIEW_TYPE, active: true }); + } + workspace.revealLeaf(leaf); + } + + restartPolling(): void { + this.stopPolling(); + this.startPolling(); + } + + private startPolling(): void { + const intervalMs = (this.settings.pollIntervalMinutes || 15) * 60 * 1000; + this.pollTimer = window.setInterval(() => this.refreshView(), intervalMs); + } + + private stopPolling(): void { + if (this.pollTimer !== null) { + window.clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + private async refreshView(): Promise { + for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE)) { + await (leaf.view as WikiSidebarView).refresh(); + } + } + + // Record updatedAt for PRs we haven't seen before so first load doesn't + // flag everything as new. + initSeenActivity(prs: PullRequest[]): void { + let changed = false; + for (const pr of prs) { + if (pr.author !== this.settings.githubUsername) continue; + if (!(pr.number in this.settings.seenPRActivity)) { + this.settings.seenPRActivity[pr.number] = pr.updatedAt; + changed = true; + } + } + if (changed) this.saveSettings(); + } + + hasNewActivity(pr: PullRequest): boolean { + if (pr.author !== this.settings.githubUsername) return false; + const lastSeen = this.settings.seenPRActivity[pr.number]; + if (!lastSeen) return false; + return pr.updatedAt > lastSeen; + } + + markSeen(pr: PullRequest): void { + this.settings.seenPRActivity[pr.number] = pr.updatedAt; + this.saveSettings(); + } +} + +// ── Settings tab ───────────────────────────────────────────────────────────── + +class GitHubWikiSettingTab extends PluginSettingTab { + plugin: GitHubWikiPlugin; + + constructor(app: App, plugin: GitHubWikiPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl("h2", { text: "GitHub Wiki Tools" }); + + new Setting(containerEl) + .setName("Local repo path") + .setDesc("Absolute path to your local clone of the wiki repo") + .addText((t) => + t + .setPlaceholder("/Users/you/wiki-repo") + .setValue(this.plugin.settings.repoPath) + .onChange(async (v) => { + this.plugin.settings.repoPath = v; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("GitHub personal access token") + .setDesc("Classic PAT with repo scope") + .addText((t) => { + t + .setPlaceholder("ghp_…") + .setValue(this.plugin.settings.githubToken) + .onChange(async (v) => { + this.plugin.settings.githubToken = v; + await this.plugin.saveSettings(); + }); + t.inputEl.type = "password"; + }); + + new Setting(containerEl) + .setName("GitHub username") + .setDesc("Your GitHub username — used to identify your PRs") + .addText((t) => + t + .setPlaceholder("your-username") + .setValue(this.plugin.settings.githubUsername) + .onChange(async (v) => { + this.plugin.settings.githubUsername = v; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Repo owner") + .setDesc("GitHub org or user that owns the wiki repo") + .addText((t) => + t + .setPlaceholder("org-name") + .setValue(this.plugin.settings.repoOwner) + .onChange(async (v) => { + this.plugin.settings.repoOwner = v; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Repo name") + .setDesc("Name of the wiki repo on GitHub") + .addText((t) => + t + .setPlaceholder("wiki-repo") + .setValue(this.plugin.settings.repoName) + .onChange(async (v) => { + this.plugin.settings.repoName = v; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Poll interval (minutes)") + .setDesc("How often to check for updates (default: 15)") + .addText((t) => + t + .setPlaceholder("15") + .setValue(String(this.plugin.settings.pollIntervalMinutes)) + .onChange(async (v) => { + const n = parseInt(v, 10); + if (!isNaN(n) && n > 0) { + this.plugin.settings.pollIntervalMinutes = n; + await this.plugin.saveSettings(); + this.plugin.restartPolling(); + } + }) + ); + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..ed20805 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "obsidian-github-tools", + "name": "GitHub Wiki Tools", + "version": "1.0.0", + "minAppVersion": "0.15.0", + "description": "Monitor your GitHub wiki repo — staleness alerts, PR dashboard, and activity notifications.", + "author": "Kyle Whittle", + "authorUrl": "", + "isDesktopOnly": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..879da6b --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "obsidian-github-tools", + "version": "1.0.0", + "description": "GitHub wiki repo monitor for Obsidian", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + }, + "keywords": [], + "author": "Kyle Whittle", + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "builtin-modules": "^3.3.0", + "esbuild": "0.17.3", + "obsidian": "latest", + "tslib": "2.4.0", + "typescript": "4.7.4" + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..048a62f --- /dev/null +++ b/styles.css @@ -0,0 +1,128 @@ +.gwt-sidebar { + padding: 12px; + font-size: 13px; +} + +.gwt-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.gwt-header h4 { + margin: 0; +} + +.gwt-btn-icon { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + color: var(--text-muted); + padding: 2px 6px; + border-radius: 4px; +} + +.gwt-btn-icon:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.gwt-section { + margin-bottom: 16px; +} + +.gwt-section h5 { + margin: 0 0 6px; + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.gwt-row { + margin-bottom: 4px; +} + +.gwt-label { + color: var(--text-muted); +} + +.gwt-warning { + color: var(--color-orange); +} + +.gwt-behind { + display: flex; + align-items: center; + gap: 8px; + color: var(--color-yellow); +} + +.gwt-ok { + color: var(--color-green); +} + +.gwt-btn { + font-size: 12px; + padding: 2px 8px; + cursor: pointer; + border-radius: 3px; +} + +.gwt-pr { + margin-bottom: 6px; + padding: 6px 8px; + border-radius: 4px; + background: var(--background-secondary); + border-left: 3px solid transparent; +} + +.gwt-pr-new { + border-left-color: var(--interactive-accent); +} + +.gwt-pr-link { + display: block; + text-decoration: none; + color: var(--text-normal); + margin-bottom: 2px; + line-height: 1.4; +} + +.gwt-pr-link:hover { + color: var(--interactive-accent); +} + +.gwt-pr-meta { + font-size: 11px; + color: var(--text-muted); +} + +.gwt-new-badge { + color: var(--interactive-accent); + font-weight: 600; +} + +.gwt-draft { + font-style: italic; +} + +.gwt-empty { + font-size: 12px; + color: var(--text-faint); + font-style: italic; +} + +.gwt-muted { + color: var(--text-muted); +} + +.gwt-error { + color: var(--color-red); + font-size: 12px; + margin-bottom: 8px; + word-break: break-word; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2537c87 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": ["DOM", "ES5", "ES6", "ES7"] + }, + "include": ["**/*.ts"] +}