diff --git a/package.json b/package.json index b6b9930..8c8fa7e 100644 --- a/package.json +++ b/package.json @@ -222,9 +222,57 @@ "command": "jj.commit", "title": "Commit changes", "category": "Jujutsu" + }, + { + "command": "ukemi.refreshComments", + "title": "Refresh GitHub Comments", + "category": "Jujutsu", + "icon": "$(refresh)" + }, + { + "command": "ukemi.replyAndResolve", + "title": "Reply & Resolve", + "category": "Jujutsu", + "icon": "$(check)" + }, + { + "command": "ukemi.replyOnly", + "title": "Reply Only", + "category": "Jujutsu", + "icon": "$(reply)" + }, + { + "command": "ukemi.markDone", + "title": "Mark Done", + "category": "Jujutsu", + "icon": "$(pass)" } ], "menus": { + "comments/commentThread/title": [ + { + "command": "ukemi.markDone", + "group": "navigation", + "when": "commentController == ukemi-comments" + } + ], + "comments/commentThread/context": [ + { + "command": "ukemi.replyAndResolve", + "group": "navigation@1", + "when": "commentController == ukemi-comments" + }, + { + "command": "ukemi.markDone", + "group": "navigation@2", + "when": "commentController == ukemi-comments" + }, + { + "command": "ukemi.replyOnly", + "group": "navigation@3", + "when": "commentController == ukemi-comments" + } + ], "editor/title": [ { "command": "jj.squashSelectedRanges", @@ -278,6 +326,11 @@ "when": "view == jjGraphWebview && jjGraphView.nodesSelected", "group": "navigation" }, + { + "command": "ukemi.refreshComments", + "when": "view == workbench.panel.comments", + "group": "navigation" + }, { "command": "jj.selectGraphWebviewRepo", "when": "view == jjGraphWebview", @@ -504,6 +557,44 @@ "description": "Path to the jj executable. If not set, your PATH and common locations will be searched for a jj executable.", "scope": "resource" }, + "ukemi.githubToken": { + "type": "string", + "default": "", + "description": "GitHub Personal Access Token (PAT) for fetching PR comments. If not set, the gh CLI's default authentication will be used.", + "scope": "resource" + }, + "ukemi.githubTokens": { + "type": "object", + "default": {}, + "description": "A map of GitHub organization names to Personal Access Tokens (PATs). Useful for working across multiple organizations with different auth requirements. PAT tokens need read/write access to Pull requests and Contents.", + "scope": "resource" + }, + "ukemi.ghPath": { + "type": "string", + "default": "", + "description": "Path to the gh executable. If not set, your PATH will be searched for a gh executable.", + "scope": "resource" + }, + "ukemi.githubRemote": { + "type": "string", + "default": "origin", + "description": "The git remote name to use for identifying the GitHub organization and repository.", + "scope": "resource" + }, + "ukemi.githubPRSearchLimit": { + "type": "number", + "default": 10, + "description": "The maximum number of parent commits to search for a matching GitHub PR.", + "scope": "resource", + "minimum": 0 + }, + "ukemi.githubPRPollInterval": { + "type": "number", + "default": 5, + "description": "Interval in minutes for background polling of GitHub PR comments. Set to 0 to disable background polling.", + "scope": "resource", + "minimum": 0 + }, "ukemi.graph.viewLayout": { "type": "string", "enum": [ diff --git a/src/commentController.ts b/src/commentController.ts new file mode 100644 index 0000000..f26fa3d --- /dev/null +++ b/src/commentController.ts @@ -0,0 +1,385 @@ +import * as vscode from "vscode"; +import { GitHubRepository, GHComment, GHPR, GHThreadInfo } from "./github"; +import { JJRepository } from "./repository"; +import { getConfig } from "./config"; +import { getLogger } from "./logger"; +import path from "path"; + +interface ThreadMetadata { + prNumber: number; + repoSlug: string; + rootCommentId: number; + threadNodeId: string; +} + +export class CommentControllerManager { + private commentController: vscode.CommentController; + private prCache = new Map(); // rev -> PR + private commentsCache = new Map(); // PR number -> comments + private threadsCache = new Map(); // PR number -> threads + private threads = new Map(); // uri -> threads + private threadMetadata = new Map(); + private currentPR: GHPR | null = null; + private currentRepoSlug: string | null = null; + private disposables: vscode.Disposable[] = []; + + constructor( + private repository: JJRepository, + private githubRepository: GitHubRepository, + ) { + this.commentController = vscode.comments.createCommentController( + "ukemi-comments", + "GitHub PR Comments", + ); + this.disposables.push(this.commentController); + + this.commentController.commentingRangeProvider = { + provideCommentingRanges: (document: vscode.TextDocument) => { + return [new vscode.Range(0, 0, document.lineCount - 1, 0)]; + }, + }; + } + + public async handleReply(thread: vscode.CommentThread, text: string, resolve: boolean) { + const metadata = this.threadMetadata.get(thread); + if (!metadata) { + return; + } + + try { + // Optimistic UI update + if (resolve) { + thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; + thread.state = vscode.CommentThreadState.Resolved; + thread.label = "PR Review (Resolved)"; + } + + if (text) { + await this.githubRepository.postReply(metadata.prNumber, metadata.repoSlug, metadata.rootCommentId, text); + } + + if (resolve) { + await this.githubRepository.resolveThread(metadata.threadNodeId, metadata.repoSlug); + } + + // Background refresh to confirm state + void this.refreshComments(thread.uri, true); + } catch (e) { + getLogger().error(`Failed to handle reply: ${String(e)}`); + vscode.window.showErrorMessage(`Failed to post reply: ${String(e)}`); + void this.refreshComments(thread.uri, true); + } + } + + public async handleMarkDone(thread: vscode.CommentThread) { + const metadata = this.threadMetadata.get(thread); + if (!metadata) { + return; + } + + try { + // Optimistic UI update + thread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; + thread.state = vscode.CommentThreadState.Resolved; + thread.label = "PR Review (Resolved)"; + + await this.githubRepository.postReply(metadata.prNumber, metadata.repoSlug, metadata.rootCommentId, "done"); + await this.githubRepository.resolveThread(metadata.threadNodeId, metadata.repoSlug); + + void this.refreshComments(thread.uri, true); + } catch (e) { + getLogger().error(`Failed to mark as done: ${String(e)}`); + vscode.window.showErrorMessage(`Failed to mark as done: ${String(e)}`); + void this.refreshComments(thread.uri, true); + } + } + + async refreshComments(uri?: vscode.Uri, force = false) { + if (uri && uri.scheme !== "file") { + return; + } + + if (force) { + this.clearCache(); + } + + const config = getConfig(uri); + const remoteName = config.githubRemote || "origin"; + + // 1. Get remote info + const remotes = await this.repository.getRemotes(); + const remoteUrl = remotes.get(remoteName); + if (!remoteUrl) { + getLogger().debug(`[GH Comments] Remote ${remoteName} not found. Available: ${Array.from(remotes.keys()).join(", ")}`); + return; + } + + const repoInfo = GitHubRepository.parseRemoteUrl(remoteUrl); + if (!repoInfo) { + getLogger().debug(`[GH Comments] Could not parse remote URL: ${remoteUrl}`); + return; + } + + const repoSlug = `${repoInfo.owner}/${repoInfo.repo}`; + + // 0. Check authentication for this org + if (!(await this.githubRepository.checkAuth(repoInfo.owner))) { + getLogger().debug(`[GH Comments] No valid authentication found for ${repoInfo.owner}. Skipping refresh.`); + this.clearComments(); + return; + } + + getLogger().debug(`[GH Comments] Refreshing comments for ${repoSlug} (force: ${force})`); + + // 2. Find PR by walking up from @ + const pr = await this.findPRForRev("@", repoSlug, config.githubPRSearchLimit); + + // If the PR changed, clear EVERYTHING + if (this.currentPR?.number !== pr?.number || this.currentRepoSlug !== repoSlug) { + getLogger().info(`[GH Comments] Active PR changed from #${this.currentPR?.number ?? "none"} to #${pr?.number ?? "none"}. Clearing all comments.`); + this.clearComments(); + this.currentPR = pr; + this.currentRepoSlug = repoSlug; + } + + if (!pr) { + getLogger().debug(`[GH Comments] No PR found for current commit stack in ${repoSlug}`); + return; + } + + // 3. Get comments and threads for the PR + let comments = this.commentsCache.get(pr.number); + let threads = this.threadsCache.get(pr.number); + if (!comments || !threads || force) { + getLogger().info(`[GH Comments] Fetching comments and threads for PR #${pr.number} in ${repoSlug}...`); + [comments, threads] = await Promise.all([ + this.githubRepository.getPRComments(pr.number, repoSlug), + this.githubRepository.getPRThreads(pr.number, repoSlug) + ]); + this.commentsCache.set(pr.number, comments); + this.threadsCache.set(pr.number, threads); + getLogger().info(`[GH Comments] Loaded ${comments.length} review comments and ${threads.length} threads for PR #${pr.number}`); + + // Immediately display ALL comments for ALL files to populate sidebar + this.displayAllComments(comments, threads, pr.number, repoSlug); + } else { + getLogger().debug(`[GH Comments] Using cached data for PR #${pr.number} (${comments.length} comments)`); + } + } + + private displayAllComments(comments: GHComment[], threads: GHThreadInfo[], prNumber: number, repoSlug: string) { + getLogger().debug(`[GH Comments] Synchronizing ${comments.length} comments across files...`); + + // Group all comments by file path + const commentsByFile = new Map(); + for (const comment of comments) { + if (!commentsByFile.has(comment.path)) { + commentsByFile.set(comment.path, []); + } + commentsByFile.get(comment.path)!.push(comment); + } + + // Clear existing threads + this.clearComments(); + + let threadCount = 0; + for (const [filePath, fileComments] of commentsByFile) { + const fullPath = path.join(this.repository.repositoryRoot, filePath); + const uri = vscode.Uri.file(fullPath); + this.displayComments(uri, fileComments, threads, prNumber, repoSlug, false); + threadCount += this.threads.get(uri.toString())?.length ?? 0; + } + getLogger().info(`[GH Comments] Created ${threadCount} comment threads across ${commentsByFile.size} files.`); + } + + private async findPRForRev(rev: string, repoSlug: string, depth: number): Promise { + if (depth <= 0) { + return null; + } + + try { + const showResult = await this.repository.show(rev); + const commitId = showResult.change.commitId; + + // Check cache using commitId + if (this.prCache.has(commitId)) { + return this.prCache.get(commitId)!; + } + + // Check bookmarks and remoteBookmarks for PR + const allBookmarks = [...showResult.change.bookmarks, ...showResult.change.remoteBookmarks]; + getLogger().debug(`Checking rev ${rev.substring(0, 8)} (${commitId.substring(0, 8)}) with bookmarks: ${allBookmarks.join(", ")}`); + + for (const bookmark of allBookmarks) { + const pr = await this.githubRepository.findPRByBranch(bookmark, repoSlug); + if (pr) { + getLogger().debug(`Matched bookmark ${bookmark} to PR #${pr.number}`); + this.prCache.set(commitId, pr); + return pr; + } + } + + // Walk up to parents + for (const parentId of showResult.change.parentChangeIds) { + const pr = await this.findPRForRev(parentId, repoSlug, depth - 1); + if (pr) { + this.prCache.set(commitId, pr); + return pr; + } + } + + this.prCache.set(commitId, null); + } catch (e) { + getLogger().error(`Error while walking up revs for PR at ${rev}: ${String(e)}`); + } + + return null; + } + + private clearCache() { + getLogger().debug("GitHub PR and comments cache cleared."); + this.prCache.clear(); + this.commentsCache.clear(); + this.threadsCache.clear(); + this.githubRepository.invalidateAuthCache(); + } + + private displayComments(uri: vscode.Uri, fileComments: GHComment[], threads: GHThreadInfo[], prNumber: number, repoSlug: string, _clearExisting = true) { + // We group comments by line number because the GitHub REST API returns a flat list of comments. + // While GraphQL provides thread threads, we currently rely on the REST data for comment content. + // Grouping by line allows us to reconstruct the conversation flow for VS Code's line-based comment model. + const threadsByLine = new Map(); + for (const comment of fileComments) { + if (comment.line === null) { + continue; + } + const line = comment.line - 1; // GH is 1-based, VS Code is 0-based + if (line < 0) { + continue; + } + if (!threadsByLine.has(line)) { + threadsByLine.set(line, []); + } + threadsByLine.get(line)!.push(comment); + } + + const uriString = uri.toString(); + const currentFileThreads = this.threads.get(uriString) || []; + const usedThreads = new Set(); + + // Create or update threads + for (const [line, lineComments] of threadsByLine) { + // Sort comments by creation date + lineComments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + + // Find the thread node_id for this conversation + const rootComment = lineComments[0]; + const threadInfo = threads.find(t => t.rootCommentDatabaseId === rootComment.id); + + // Try to find an existing thread for this line and root comment + // We use the root comment ID to uniquely identify the conversation + let thread = currentFileThreads.find(t => { + const meta = this.threadMetadata.get(t); + return meta?.rootCommentId === rootComment.id && t.range && t.range.start.line === line; + }); + + const newComments = lineComments.map(c => this.mapToVSCodeComment(c)); + + if (thread) { + // Update existing thread + thread.comments = newComments; + usedThreads.add(thread); + } else { + // Create new thread + thread = this.commentController.createCommentThread( + uri, + new vscode.Range(line, 0, line, 0), + newComments + ); + currentFileThreads.push(thread); + usedThreads.add(thread); + } + + if (threadInfo) { + this.threadMetadata.set(thread, { + prNumber, + repoSlug, + rootCommentId: rootComment.id, + threadNodeId: threadInfo.id + }); + } + + // Update state + const isResolved = threadInfo?.isResolved ?? false; + thread.collapsibleState = isResolved + ? vscode.CommentThreadCollapsibleState.Collapsed + : vscode.CommentThreadCollapsibleState.Expanded; + thread.state = isResolved + ? vscode.CommentThreadState.Resolved + : vscode.CommentThreadState.Unresolved; + thread.label = `PR Review${isResolved ? " (Resolved)" : ""}`; + } + + // Dispose of threads that are no longer present for this file + // active threads are in usedThreads + const keptThreads: vscode.CommentThread[] = []; + for (const thread of currentFileThreads) { + if (!usedThreads.has(thread)) { + this.threadMetadata.delete(thread); + thread.dispose(); + } else { + keptThreads.push(thread); + } + } + + if (keptThreads.length > 0) { + this.threads.set(uriString, keptThreads); + } else { + this.threads.delete(uriString); + } + } + + private mapToVSCodeComment(ghComment: GHComment): vscode.Comment { + return { + author: { + name: ghComment.user.login, + }, + body: new vscode.MarkdownString(ghComment.body), + mode: vscode.CommentMode.Preview, + contextValue: "gh-comment", + }; + } + + private clearComments(uri?: vscode.Uri) { + if (uri) { + const uriString = uri.toString(); + const existingThreads = this.threads.get(uriString); + if (existingThreads) { + existingThreads.forEach(t => { + this.threadMetadata.delete(t); + t.dispose(); + }); + this.threads.delete(uriString); + } + } else { + for (const uriString of this.threads.keys()) { + const existingThreads = this.threads.get(uriString); + if (existingThreads) { + existingThreads.forEach(t => { + this.threadMetadata.delete(t); + t.dispose(); + }); + } + } + this.threads.clear(); + this.threadMetadata.clear(); + } + } + + dispose() { + this.clearComments(); + this.disposables.forEach((d) => { + d.dispose(); + }); + } +} diff --git a/src/config.ts b/src/config.ts index 20401c7..f35ca02 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,12 @@ export interface Config { enableAnnotations: boolean; commandTimeout: number | null; jjPath: string; + githubToken: string; + githubTokens: Record; + githubRemote: string; + githubPRSearchLimit: number; + githubPRPollInterval: number; + ghPath: string; graph: GraphConfig; } @@ -45,6 +51,12 @@ export function getConfig(scope?: vscode.Uri): Config { enableAnnotations: config.get("enableAnnotations", true), commandTimeout: config.get("commandTimeout", null), jjPath: config.get("jjPath", ""), + githubToken: config.get("githubToken", ""), + githubTokens: config.get>("githubTokens", {}), + githubRemote: config.get("githubRemote", "origin"), + githubPRSearchLimit: config.get("githubPRSearchLimit", 10), + githubPRPollInterval: config.get("githubPRPollInterval", 5), + ghPath: config.get("ghPath", ""), graph: getGraphConfig(scope), }; } diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 0000000..7dd591c --- /dev/null +++ b/src/github.ts @@ -0,0 +1,293 @@ +import * as vscode from "vscode"; +import spawn from "cross-spawn"; +import { getConfig } from "./config"; +import { getLogger } from "./logger"; +import { ChildProcess } from "child_process"; + +export interface GHComment { + id: number; + node_id: string; + body: string; + path: string; + line: number | null; + user: { + login: string; + }; + created_at: string; + diff_hunk: string; +} + +export interface GHPR { + number: number; + url: string; + headRefName: string; + title: string; +} + +export interface GHThreadInfo { + id: string; // Thread node_id + isResolved: boolean; + rootCommentDatabaseId: number; +} + +interface GraphQLResponse { + data: { + repository: { + pullRequest: { + reviewThreads: { + nodes: Array<{ + id: string; + isResolved: boolean; + comments: { + nodes: Array<{ + databaseId: number; + }>; + }; + }>; + }; + }; + }; + }; +} + +export class GitHubRepository { + constructor( + private repositoryRoot: string, + private ghPath: string, + ) {} + + private isCachedAuthValid: boolean | undefined; + + async checkAuth(owner?: string): Promise { + const config = getConfig(vscode.Uri.file(this.repositoryRoot)); + if (config.githubToken || (owner && config.githubTokens[owner])) { + return true; + } + + if (this.isCachedAuthValid !== undefined) { + return this.isCachedAuthValid; + } + + try { + // Check if the user is already authenticated via gh CLI + await this.handleCommand(spawn(this.ghPath, ["auth", "status"], { + cwd: this.repositoryRoot, + env: process.env + })); + this.isCachedAuthValid = true; + } catch { + this.isCachedAuthValid = false; + } + return this.isCachedAuthValid; + } + + invalidateAuthCache() { + this.isCachedAuthValid = undefined; + } + + private async handleCommand(childProcess: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const output: Buffer[] = []; + const errOutput: Buffer[] = []; + childProcess.stdout!.on("data", (data: Buffer) => { + output.push(data); + }); + childProcess.stderr!.on("data", (data: Buffer) => { + errOutput.push(data); + }); + childProcess.on("error", (error: Error) => { + reject(new Error(`Spawning gh failed: ${error.message}`)); + }); + childProcess.on("close", (code, signal) => { + if (code) { + reject( + new Error( + `gh failed with exit code ${code}.\nstdout: ${Buffer.concat(output).toString()}\nstderr: ${Buffer.concat(errOutput).toString()}`, + ), + ); + } else if (signal) { + reject( + new Error( + `gh failed with signal ${signal}.\nstdout: ${Buffer.concat(output).toString()}\nstderr: ${Buffer.concat(errOutput).toString()}`, + ), + ); + } else { + resolve(Buffer.concat(output)); + } + }); + }); + } + + private async spawnGH(args: string[], owner?: string): Promise { + const config = getConfig(vscode.Uri.file(this.repositoryRoot)); + const env: NodeJS.ProcessEnv = { ...process.env }; + + let token = config.githubToken; + if (owner && config.githubTokens[owner]) { + token = config.githubTokens[owner]; + } + + if (token) { + const trimmedToken = token.trim(); + env["GH_TOKEN"] = trimmedToken; + env["GITHUB_TOKEN"] = trimmedToken; + } + + const commandStr = `${this.ghPath} ${args.join(" ")}`; + getLogger().debug(`Executing gh command: ${commandStr}`); + + const cp = spawn(this.ghPath, args, { + cwd: this.repositoryRoot, + env, + }); + + try { + const output = await this.handleCommand(cp); + return output; + } catch (e) { + getLogger().error(`gh command failed: ${commandStr}\n${String(e)}`); + throw e; + } + } + + static parseRemoteUrl(url: string): { owner: string; repo: string } | undefined { + // Matches: + // git@github.com:owner/repo.git + // https://github.com/owner/repo.git + // https://github.com/owner/repo + const match = url.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?$/); + if (match) { + return { owner: match[1], repo: match[2] }; + } + return undefined; + } + + async findPRByBranch(branchName: string, repoSlug: string): Promise { + try { + const owner = repoSlug.split("/")[0]; + const args = ["pr", "list", "--head", branchName, "--json", "number,url,headRefName,title"]; + if (repoSlug) { + args.push("--repo", repoSlug); + } + const output = await this.spawnGH(args, owner); + try { + const prs = JSON.parse(output.toString()) as GHPR[]; + return prs.length > 0 ? prs[0] : undefined; + } catch (e) { + getLogger().error(`Failed to parse PR list output: ${String(e)}`); + return undefined; + } + } catch (e) { + getLogger().error(`Failed to find PR for branch ${branchName}: ${String(e)}`); + return undefined; + } + } + + async getPRComments(prNumber: number, repoSlug: string): Promise { + try { + const owner = repoSlug.split("/")[0]; + // Use gh api to fetch review comments directly + const args = ["api", `repos/${repoSlug}/pulls/${prNumber}/comments`]; + const output = await this.spawnGH(args, owner); + try { + return JSON.parse(output.toString()) as GHComment[]; + } catch (e) { + getLogger().error(`Failed to parse PR comments output: ${String(e)}`); + return []; + } + } catch (e) { + getLogger().error(`Failed to fetch comments for PR #${prNumber}: ${String(e)}`); + return []; + } + } + + async getPRThreads(prNumber: number, repoSlug: string): Promise { + try { + const [owner, repo] = repoSlug.split("/"); + const query = ` + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number: $number) { + reviewThreads(last: 100) { + nodes { + id + isResolved + comments(first: 1) { + nodes { + databaseId + } + } + } + } + } + } + } + `; + const output = await this.spawnGH([ + "api", + "graphql", + "-f", `query=${query}`, + "-f", `owner=${owner}`, + "-f", `repo=${repo}`, + "-F", `number=${prNumber}` + ], owner); + try { + const response = JSON.parse(output.toString()) as GraphQLResponse; + const threads = response.data.repository.pullRequest.reviewThreads.nodes; + return threads.map((t) => ({ + id: t.id, + isResolved: t.isResolved, + rootCommentDatabaseId: t.comments.nodes[0]?.databaseId + })).filter((t): t is GHThreadInfo => t.rootCommentDatabaseId !== undefined); + } catch (e) { + getLogger().error(`Failed to parse PR threads output: ${String(e)}`); + return []; + } + } catch (e) { + getLogger().error(`Failed to fetch threads for PR #${prNumber}: ${String(e)}`); + return []; + } + } + + async postReply(prNumber: number, repoSlug: string, inReplyTo: number, body: string): Promise { + try { + const owner = repoSlug.split("/")[0]; + const args = [ + "api", + `repos/${repoSlug}/pulls/${prNumber}/comments`, + "-f", `body=${body}`, + "-F", `in_reply_to=${inReplyTo}` + ]; + const output = await this.spawnGH(args, owner); + try { + return JSON.parse(output.toString()) as GHComment; + } catch (e) { + getLogger().error(`Failed to parse reply output: ${String(e)}`); + return undefined; + } + } catch (e) { + getLogger().error(`Failed to post reply to comment ${inReplyTo}: ${String(e)}`); + return undefined; + } + } + + async resolveThread(threadNodeId: string, repoSlug: string): Promise { + try { + const owner = repoSlug.split("/")[0]; + const query = ` + mutation($id: ID!) { + resolveReviewThread(input: { threadId: $id }) { + thread { + isResolved + } + } + } + `; + await this.spawnGH(["api", "graphql", "-f", `query=${query}`, "-f", `id=${threadNodeId}`], owner); + return true; + } catch (e) { + getLogger().error(`Failed to resolve thread ${threadNodeId}: ${String(e)}`); + return false; + } + } +} diff --git a/src/main.ts b/src/main.ts index b4a5518..90c7712 100644 --- a/src/main.ts +++ b/src/main.ts @@ -104,6 +104,11 @@ export async function activate(context: vscode.ExtensionContext) { await checkReposFunction(affectedFolders); } } + if (e.affectsConfiguration("ukemi")) { + logger.info("Ukemi configuration changed"); + // Trigger a poll with force=true to re-verify auth and refresh comments + void poll(true); + } }); let isInitialized = false; @@ -333,6 +338,11 @@ export async function activate(context: vscode.ExtensionContext) { (selection) => selection.active.line, ); await setDecorations(editor, activeLines); + + const repositorySCM = workspaceSCM.getRepositorySourceControlManagerFromUri(uri); + if (repositorySCM?.commentControllerManager) { + void repositorySCM.commentControllerManager.refreshComments(uri); + } } }; context.subscriptions.push( @@ -1429,8 +1439,7 @@ export async function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push( - vscode.commands.registerCommand( - "jj.commit", + vscode.commands.registerCommand("jj.commit", showLoading(async (sourceControl: vscode.SourceControl) => { try { const repository = @@ -1450,10 +1459,54 @@ export async function activate(context: vscode.ExtensionContext) { ), ); + context.subscriptions.push( + vscode.commands.registerCommand("ukemi.refreshComments", async () => { + getLogger().info("[GH Comments] Manual refresh triggered from UI."); + await Promise.all( + workspaceSCM.repoSCMs + .filter((repoSCM) => !!repoSCM.commentControllerManager) + .map((repoSCM) => + repoSCM.commentControllerManager!.refreshComments( + undefined, + true, + ), + ), + ); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "ukemi.replyAndResolve", + async (reply: vscode.CommentReply) => { + const thread = reply.thread; + const text = reply.text; + const repositorySCM = workspaceSCM.getRepositorySourceControlManagerFromUri(thread.uri); + if (repositorySCM?.commentControllerManager) { + await repositorySCM.commentControllerManager.handleReply(thread, text, true); + } + }), + vscode.commands.registerCommand("ukemi.replyOnly", async (reply: vscode.CommentReply) => { + const thread = reply.thread; + const text = reply.text; + const repositorySCM = workspaceSCM.getRepositorySourceControlManagerFromUri(thread.uri); + if (repositorySCM?.commentControllerManager) { + await repositorySCM.commentControllerManager.handleReply(thread, text, false); + } + }), + vscode.commands.registerCommand("ukemi.markDone", async (arg: vscode.CommentThread | vscode.CommentReply) => { + const thread = "thread" in arg ? arg.thread : arg; + const repositorySCM = workspaceSCM.getRepositorySourceControlManagerFromUri(thread.uri); + if (repositorySCM?.commentControllerManager) { + await repositorySCM.commentControllerManager.handleMarkDone(thread); + } + }), + ); + isInitialized = true; } - async function poll() { + async function poll(force = false) { const didUpdate = await workspaceSCM.refresh(); if (didUpdate) { setSelectedRepo(getSelectedRepo()); @@ -1469,14 +1522,14 @@ export async function activate(context: vscode.ExtensionContext) { // Snapshot changes await Promise.all( - workspaceSCM.repoSCMs.map((repoSCM) => repoSCM.checkForUpdates()), + workspaceSCM.repoSCMs.map((repoSCM) => repoSCM.checkForUpdates(force)), ); } context.subscriptions.push( vscode.commands.registerCommand( "jj.refresh", - showLoading(() => poll()), + showLoading(() => poll(true)), ), ); @@ -1628,10 +1681,68 @@ export async function activate(context: vscode.ExtensionContext) { void scheduleNextPoll(); // Start the first poll. + let ghPollTimer: NodeJS.Timeout | undefined; + + const performGHCommentsPoll = async () => { + if (isPollingCanceled) { return; } + try { + const config = getConfig(); + if (config.githubPRPollInterval <= 0) { return; } + + getLogger().info(`[GH Comments] Background refresh triggered.`); + await Promise.all( + workspaceSCM.repoSCMs + .filter((repoSCM) => !!repoSCM.commentControllerManager) + .map((repoSCM) => + repoSCM.commentControllerManager!.refreshComments(undefined, true) + ) + ); + } catch (err) { + logger.error(`Error during GitHub comment background poll: ${String(err)}`); + } + }; + + const stopGHPolling = () => { + if (ghPollTimer) { + clearInterval(ghPollTimer); + ghPollTimer = undefined; + } + }; + + const startGHPolling = () => { + stopGHPolling(); + if (isPollingCanceled) { return; } + + const config = getConfig(); + const intervalMinutes = config.githubPRPollInterval; + if (intervalMinutes > 0) { + getLogger().info(`[GH Comments] Starting background polling every ${intervalMinutes} minutes.`); + // Run immediately + void performGHCommentsPoll(); + ghPollTimer = setInterval(() => { + void performGHCommentsPoll(); + }, intervalMinutes * 60_000); + } else { + getLogger().info(`[GH Comments] Background polling disabled.`); + } + }; + + // Start initial polling + startGHPolling(); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("ukemi.githubPRPollInterval")) { + startGHPolling(); + } + }) + ); + context.subscriptions.push( new vscode.Disposable(() => { isPollingCanceled = true; clearTimeout(pollTimeoutId); + stopGHPolling(); }), ); } diff --git a/src/repository.ts b/src/repository.ts index 8184dd3..2181a8f 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -12,6 +12,8 @@ import * as crypto from "crypto"; import which from "which"; import { getConfig } from "./config"; import { getLogger } from "./logger"; +import { GitHubRepository } from "./github"; +import { CommentControllerManager } from "./commentController"; async function getJJVersion(jjPath: string): Promise { try { @@ -509,6 +511,7 @@ class RepositorySourceControlManager { workingCopyResourceGroup: vscode.SourceControlResourceGroup; parentResourceGroups: vscode.SourceControlResourceGroup[] = []; repository: JJRepository; + commentControllerManager: CommentControllerManager | undefined; checkForUpdatesPromise: Promise | undefined; private _onDidUpdate = new vscode.EventEmitter(); @@ -536,6 +539,14 @@ class RepositorySourceControlManager { jjConfigArgs, ); + const config = getConfig(vscode.Uri.file(repositoryRoot)); + const ghPath = config.ghPath || "gh"; + this.commentControllerManager = new CommentControllerManager( + this.repository, + new GitHubRepository(repositoryRoot, ghPath), + ); + this.subscriptions.push(this.commentControllerManager); + this.sourceControl = vscode.scm.createSourceControl( "jj", path.basename(repositoryRoot), @@ -587,9 +598,9 @@ class RepositorySourceControlManager { ); } - async checkForUpdates() { + async checkForUpdates(force = false) { if (!this.checkForUpdatesPromise) { - this.checkForUpdatesPromise = this.checkForUpdatesUnsafe(); + this.checkForUpdatesPromise = this.checkForUpdatesUnsafe(force); try { await this.checkForUpdatesPromise; } finally { @@ -603,15 +614,24 @@ class RepositorySourceControlManager { /** * This should never be called concurrently. */ - async checkForUpdatesUnsafe() { + async checkForUpdatesUnsafe(force = false) { const latestOperationId = await this.repository.getLatestOperationId(); - if (this.operationId !== latestOperationId) { + if (this.operationId !== latestOperationId || force) { this.operationId = latestOperationId; const status = await this.repository.status(); await this.updateState(status); this.render(); + if (this.commentControllerManager) { + const activeEditor = vscode.window.activeTextEditor; + const uri = (activeEditor && !path.relative(this.repositoryRoot, activeEditor.document.uri.fsPath).startsWith("..")) + ? activeEditor.document.uri + : undefined; + + void this.commentControllerManager.refreshComments(uri, force); + } + this._onDidUpdate.fire(undefined); } } @@ -934,6 +954,26 @@ export class JJRepository { .split("\n"); } + async getRemotes(): Promise> { + const output = ( + await handleJJCommand( + this.spawnJJ(["git", "remote", "list"], { + timeout: 5000, + cwd: this.repositoryRoot, + }), + ) + ).toString(); + + const remotes = new Map(); + for (const line of output.split("\n").filter(Boolean)) { + const parts = line.split(/\s+/); + if (parts.length >= 2) { + remotes.set(parts[0], parts[1]); + } + } + return remotes; + } + async show(rev: string) { const results = await this.showAll([rev]); if (results.length > 1) { @@ -999,6 +1039,15 @@ export class JJRepository { .filter(Boolean); }, }, + { + template: 'remote_bookmarks.map(|b| b.name()).join(",")', + setter: (value, show) => { + show.change.remoteBookmarks = value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + }, + }, { template: "description", setter: (value, show) => { @@ -1077,6 +1126,7 @@ export class JJRepository { return revResults.map((revResult) => { const fields = revResult.split(fieldSeparator); if (fields.length > templateFields.length) { + getLogger().error(`Separator found in a field value. Fields: ${JSON.stringify(fields)}. Raw result: ${revResult}`); throw new Error( "Separator found in a field value. This is not supported.", ); @@ -1095,6 +1145,7 @@ export class JJRepository { authoredDate: "", parentChangeIds: [], bookmarks: [], + remoteBookmarks: [], isEmpty: false, isConflict: false, isImmutable: false, @@ -2022,6 +2073,7 @@ export interface Change { changeId: string; commitId: string; bookmarks: string[]; + remoteBookmarks: string[]; description: string; isEmpty: boolean; isConflict: boolean; @@ -2077,6 +2129,7 @@ async function parseJJStatus( isConflict: false, isImmutable: false, bookmarks: [], + remoteBookmarks: [], }; const parentCommits: Change[] = []; @@ -2203,6 +2256,7 @@ async function parseJJStatus( bookmarks: bookmarks ? (await stripAnsiCodes(bookmarks)).split(/\s+/) : [], + remoteBookmarks: [], description: cleanedDescription, isEmpty, isConflict, diff --git a/src/test/repository.test.ts b/src/test/repository.test.ts index cc32afd..a66fd54 100644 --- a/src/test/repository.test.ts +++ b/src/test/repository.test.ts @@ -44,6 +44,7 @@ suite("JJRepository", () => { assert.strictEqual(status.parentChanges.length, 1); assert.deepStrictEqual(status.parentChanges[0], { bookmarks: [], + remoteBookmarks: [], changeId: "zzzzzzzz", commitId: "00000000", description: "", @@ -110,6 +111,7 @@ suite("JJRepository", () => { commitId: "0000000000000000000000000000000000000000", parentChangeIds: [], bookmarks: [], + remoteBookmarks: [], description: "", isConflict: false, isEmpty: true,