@ihildy/google-jules-workflow
Version:
A comprehensive toolkit for optimizing AI-powered development workflows, specifically designed for Google Jules AI coding agent integration with GitHub and Linear.
1,369 lines (1,226 loc) • 44.6 kB
text/typescript
import { execSync } from "child_process";
import { LinearClient } from "@linear/sdk";
import clipboardy from "clipboardy";
import dotenv from "dotenv";
import { GoogleGenAI } from "@google/genai";
import { createInterface } from "readline";
import {
getConfig,
getAllPriorityKeywords,
JulesWorkflowConfig,
isConfigFound,
showConfigWarning,
} from "./config-loader";
// Load environment variables from .env file
dotenv.config();
// Load configuration
const config = getConfig();
// Show warning if no config file found
if (!isConfigFound()) {
showConfigWarning();
}
// Global flag to control logging output
let suppressLogs = false;
const logInfo = (message: string) => {
if (!suppressLogs && config.display.enableColors)
console.log("\x1b[36m%s\x1b[0m", message);
else if (!suppressLogs) console.log(message);
}; // Cyan
const logSuccess = (message: string) => {
if (!suppressLogs && config.display.enableColors)
console.log("\x1b[32m%s\x1b[0m", message);
else if (!suppressLogs) console.log(message);
}; // Green
const logError = (message: string) => {
if (!suppressLogs && config.display.enableColors)
console.error("\x1b[31m%s\x1b[0m", message);
else if (!suppressLogs) console.error(message);
}; // Red
const logWarning = (message: string) => {
if (!suppressLogs && config.display.enableColors)
console.warn("\x1b[33m%s\x1b[0m", message);
else if (!suppressLogs) console.warn(message);
}; // Yellow
// Handle both npm script usage and direct CLI usage
function normalizeArgs(args: string[]): string[] {
// If called directly (e.g., jules-extract-pr), process all args
// If called via npm/tsx, skip the script path
const scriptName = args.find((arg) =>
arg.endsWith("extract-pr-discussion.ts")
);
if (scriptName) {
const scriptIndex = args.indexOf(scriptName);
return args.slice(scriptIndex + 1);
}
return args;
}
interface PRInfo {
number: number;
owner: string;
repo: string;
branch?: string;
}
interface LinearIssueInfo {
id: string;
title: string;
description?: string;
comments: Array<{
body: string;
user: {
name: string;
};
createdAt: Date;
}>;
attachments: Array<{
url: string;
title?: string;
}>;
branchName?: string;
url: string;
state: string;
priority: number;
priorityLabel: string;
assignee: {
name: string;
email: string;
} | null;
team: string;
labels: string[];
}
interface Comment {
author: string;
body: string;
path?: string;
line?: number;
diff_hunk?: string;
created_at?: string;
state?: string;
isBot?: boolean;
priority?: "HIGH" | "MEDIUM" | "LOW";
}
interface ExtractedData {
prInfo: PRInfo | null;
linearInfo: LinearIssueInfo | null;
prDetails?: any;
reviews: any[];
reviewComments: any[];
issueComments: any[];
}
// Use configuration instead of hardcoded constants
const getBotUsers = () => config.filtering.botUsers;
const getPriorityKeywords = () => getAllPriorityKeywords(config);
// Create readline interface for user input - moved outside functions for proper lifecycle
let rl: any = null;
function createReadlineInterface() {
if (!rl) {
rl = createInterface({
input: process.stdin,
output: process.stdout,
});
}
return rl;
}
function closeReadlineInterface() {
if (rl) {
rl.close();
rl = null;
}
}
function detectPriority(body: string): "HIGH" | "MEDIUM" | "LOW" {
const lowerBody = body.toLowerCase();
const priorityKeywords = getPriorityKeywords();
for (const [priority, keywords] of Object.entries(priorityKeywords)) {
if (keywords.some((keyword) => lowerBody.includes(keyword))) {
return priority as "HIGH" | "MEDIUM" | "LOW";
}
}
return config.priority.defaultPriority;
}
function truncateCodeContext(
diffHunk: string,
maxLines: number = config.codeContext.maxCodeLines
): string {
if (!config.codeContext.enableTruncation) return diffHunk;
const lines = diffHunk.split("\n");
if (lines.length <= maxLines) return diffHunk;
return lines.slice(0, maxLines).join("\n") + "\n... (truncated)";
}
function deduplicateComments(comments: Comment[]): Comment[] {
if (!config.filtering.enableDeduplication) return comments;
const seen = new Set<string>();
return comments.filter((comment) => {
const key = `${comment.author}:${comment.body.substring(0, 100)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function getCurrentRepoInfo(): Promise<{ owner: string; repo: string }> {
try {
const remoteUrl = execSync("git config --get remote.origin.url", {
encoding: "utf-8",
}).trim();
const match = remoteUrl.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
if (!match) {
throw new Error("Could not parse GitHub repository from remote URL");
}
return { owner: match[1], repo: match[2] };
} catch (error) {
logError("Failed to get repository information from git remote");
throw error;
}
}
async function promptUser(question: string): Promise<string> {
const readline = createReadlineInterface();
return new Promise((resolve) => {
readline.question(question, (answer: string) => {
resolve(answer.trim());
});
});
}
async function fetchLinearIssue(
issueId: string
): Promise<LinearIssueInfo | null> {
const linearApiKey = process.env.LINEAR_API_KEY;
if (!linearApiKey) {
logWarning("LINEAR_API_KEY not found in environment variables");
return null;
}
const linear = new LinearClient({
apiKey: linearApiKey,
});
try {
const issue = await linear.issue(issueId);
if (!issue) {
return null;
}
logInfo(`✅ Found Linear issue: ${issue.title}`);
// Get issue comments
const comments = await issue.comments();
const commentsList = await comments.nodes;
// Get additional metadata
const state = await issue.state;
const assignee = await issue.assignee;
const team = await issue.team;
const labels = await issue.labels();
const linearInfo: LinearIssueInfo = {
id: issueId,
title: issue.title,
description: issue.description,
branchName: issue.branchName,
url: issue.url,
state: state?.name || "Unknown",
priority: issue.priority || 0,
priorityLabel: issue.priorityLabel || "None",
assignee: assignee
? {
name: assignee.displayName || assignee.name || "Unknown User",
email: assignee.email,
}
: null,
team: team?.name || "Unknown",
labels: labels.nodes.map((label) => label.name),
comments: commentsList.map((comment) => ({
body: comment.body,
user: {
name:
(comment.user as any)?.displayName ||
(comment.user as any)?.name ||
"Unknown User",
},
createdAt: comment.createdAt,
})),
attachments: [],
};
// Get attachments if available
try {
const attachments = await issue.attachments();
const attachmentsList = await attachments.nodes;
linearInfo.attachments = attachmentsList.map((attachment) => ({
url: attachment.url,
title: attachment.title,
}));
} catch (error) {
// Ignore attachment errors
}
return linearInfo;
} catch (error) {
logWarning(`Could not fetch Linear issue ${issueId}: ${error}`);
return null;
}
}
async function findPRFromLinearAttachments(
linearInfo: LinearIssueInfo
): Promise<PRInfo | null> {
// Check attachments for GitHub PR links
for (const attachment of linearInfo.attachments) {
const prMatch = attachment.url?.match(
/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/
);
if (prMatch) {
const prInfo = {
owner: prMatch[1],
repo: prMatch[2],
number: parseInt(prMatch[3]),
};
logInfo(
`✅ Found GitHub PR from Linear attachment: ${prInfo.owner}/${prInfo.repo}#${prInfo.number}`
);
return prInfo;
}
}
return null;
}
async function findPRFromBranch(branchName: string): Promise<PRInfo | null> {
try {
const { owner, repo } = await getCurrentRepoInfo();
const prListOutput = execSync(
`gh pr list --head ${branchName} --repo ${owner}/${repo} --json number`,
{ encoding: "utf-8" }
);
const prs = JSON.parse(prListOutput);
if (prs.length > 0) {
const prInfo = {
owner,
repo,
number: prs[0].number,
branch: branchName,
};
logInfo(`✅ Found GitHub PR for branch ${branchName}: #${prInfo.number}`);
return prInfo;
}
} catch (error) {
// Ignore errors
}
return null;
}
async function fetchPRData(prInfo: PRInfo): Promise<{
prDetails: any;
reviews: any[];
reviewComments: any[];
issueComments: any[];
} | null> {
const { owner, repo, number } = prInfo;
try {
// Check if PR exists
const prCheck = execSync(
`gh api repos/${owner}/${repo}/pulls/${number} --jq '.title'`,
{ encoding: "utf-8" }
).trim();
if (!prCheck) {
return null;
}
logInfo(`✅ Found GitHub PR: ${prCheck}`);
// Fetch all data in parallel for better performance
const [reviewsOutput, commentsOutput, issueCommentsOutput, prDetails] =
await Promise.all([
// Get PR reviews
new Promise<string>((resolve) => {
const output = execSync(
`gh api repos/${owner}/${repo}/pulls/${number}/reviews --jq '.[] | select(.body != "") | {author: .user.login, body: .body, state: .state}' | cat`,
{ encoding: "utf-8" }
);
resolve(output);
}),
// Get PR review comments (inline comments)
new Promise<string>((resolve) => {
const output = execSync(
`gh api repos/${owner}/${repo}/pulls/${number}/comments --jq '.[] | {author: .user.login, body: .body, path: .path, line: .line, diff_hunk: .diff_hunk}' | cat`,
{ encoding: "utf-8" }
);
resolve(output);
}),
// Get general PR comments
new Promise<string>((resolve) => {
const output = execSync(
`gh api repos/${owner}/${repo}/issues/${number}/comments --jq '.[] | {author: .user.login, body: .body, created_at: .created_at}' | cat`,
{ encoding: "utf-8" }
);
resolve(output);
}),
// Get PR details
new Promise<string>((resolve) => {
const output = execSync(
`gh api repos/${owner}/${repo}/pulls/${number} --jq '{title: .title, body: .body, head_ref: .head.ref, base_ref: .base.ref, state: .state, draft: .draft}'`,
{ encoding: "utf-8" }
);
resolve(output);
}),
]);
return {
prDetails: JSON.parse(prDetails),
reviews: reviewsOutput.trim()
? reviewsOutput
.trim()
.split("\n")
.map((line) => JSON.parse(line))
: [],
reviewComments: commentsOutput.trim()
? commentsOutput
.trim()
.split("\n")
.map((line) => JSON.parse(line))
: [],
issueComments: issueCommentsOutput.trim()
? issueCommentsOutput
.trim()
.split("\n")
.map((line) => JSON.parse(line))
: [],
};
} catch (error) {
logWarning(`Could not fetch GitHub PR ${number}: ${error}`);
return null;
}
}
async function generateAISummary(
extractedData: ExtractedData
): Promise<string> {
const geminiApiKey = process.env.GEMINI_API_KEY;
if (!geminiApiKey) {
logWarning("GEMINI_API_KEY not found - skipping AI summary");
return "⚠️ AI Summary unavailable (GEMINI_API_KEY not configured)";
}
try {
const genai = new GoogleGenAI({ apiKey: geminiApiKey });
// Create context for AI
const context = {
prTitle: extractedData.prDetails?.title || "Unknown",
linearTitle: extractedData.linearInfo?.title || "None",
totalReviews: extractedData.reviews.length,
totalComments:
extractedData.reviewComments.length +
extractedData.issueComments.length,
reviews: extractedData.reviews.slice(0, config.aiSummary.maxReviewsForAI),
comments: [
...extractedData.reviewComments,
...extractedData.issueComments,
].slice(0, config.aiSummary.maxCommentsForAI),
};
// Use custom prompt if provided, otherwise use default
let prompt = config.aiSummary.customPrompt;
if (!prompt) {
prompt = `Analyze this PR/issue discussion and provide a concise summary:
PR: ${context.prTitle}
Linear Issue: ${context.linearTitle}
Total Reviews: ${context.totalReviews}
Total Comments: ${context.totalComments}
Key Feedback:
${JSON.stringify(context.reviews, null, 2)}
${JSON.stringify(context.comments, null, 2)}
Please provide:
${
config.aiSummary.includeSections.mainConcerns
? "1. 🎯 Main concerns or issues raised\n"
: ""
}${
config.aiSummary.includeSections.criticalItems
? "2. 🚨 Critical items that need immediate attention\n"
: ""
}${
config.aiSummary.includeSections.nextSteps
? "3. 📋 Recommended next steps\n"
: ""
}${
config.aiSummary.includeSections.complexity
? "4. ⏱️ Estimated complexity (Low/Medium/High)\n"
: ""
}
Keep it concise and actionable for a developer.`;
} else {
// Replace placeholder in custom prompt
prompt = prompt.replace("{context}", JSON.stringify(context, null, 2));
}
const result = await genai.models.generateContent({
model: config.aiSummary.model,
contents: prompt,
});
return result.text || "⚠️ AI Summary generated but text was empty";
} catch (error) {
logWarning(`AI summary failed: ${error}`);
return "⚠️ AI Summary failed - check GEMINI_API_KEY and rate limits";
}
}
function formatOutput(extractedData: ExtractedData): string {
const {
prInfo,
linearInfo,
prDetails,
reviews,
reviewComments,
issueComments,
} = extractedData;
let output = "";
const sections: Record<string, string> = {};
// For Linear-only issues (no PR found), use simplified format
if (linearInfo && !prInfo) {
// Header section
if (config.output.includeLinearIdHeader) {
sections.header = `${linearInfo.id}\n\n`;
}
// Title
sections.title = `# ${linearInfo.title}\n\n`;
// Description
if (linearInfo.description && config.integrations.linear.includeComments) {
sections.description = `${linearInfo.description}\n\n`;
}
// Metadata section
if (config.output.includeMetadata) {
let metadata = `## Metadata\n`;
metadata += `- URL: [${linearInfo.url}](${linearInfo.url})\n`;
metadata += `- Identifier: ${linearInfo.id}\n`;
metadata += `- Status: ${linearInfo.state}\n`;
if (linearInfo.assignee) {
metadata += `- Assignee: ${linearInfo.assignee.name}\n`;
}
if (linearInfo.labels && linearInfo.labels.length > 0) {
metadata += `- Labels: ${linearInfo.labels.join(", ")}\n`;
}
metadata += `- Priority: ${linearInfo.priorityLabel}\n`;
metadata += `- Team: ${linearInfo.team}\n\n`;
sections.metadata = metadata;
}
// Comments section
if (
linearInfo.comments &&
linearInfo.comments.length > 0 &&
config.output.includeLinearDiscussion
) {
let comments = `## Comments\n\n`;
linearInfo.comments.forEach((comment) => {
comments += `- ${comment.user.name}:\n\n ${comment.body}\n\n`;
});
sections.comments = comments;
}
// Jules Rules for Linear-only issues
if (config.output.includeJulesRules) {
let rulesSection = `${config.display.customHeaders.julesRules}\n`;
// Use custom rules if provided, otherwise use default
const rulesToUse =
config.output.customJulesRules.length > 0
? config.output.customJulesRules
: [
"You don't have access to project environmental variables. If you must make an edit / migration of the database please instead edit prisma schema file and leave it as is, assuming a human will migrate it later.",
"When publishing the github branch the name MUST BE THE FULL EXACT the FROM branch mentioned at the top of the output (but not the shortened version at the top of the output). For Linear-only issues without an existing branch, create an appropriate branch name from the Linear issue ID and title.",
];
rulesToUse.forEach((rule) => {
rulesSection += `- ${rule}\n`;
});
// Add additional rules
config.output.additionalJulesRules.forEach((rule) => {
rulesSection += `- ${rule}\n`;
});
sections.julesRules = rulesSection;
}
// Build output in the order: header, title, description, metadata, comments, julesRules
return [
sections.header || "",
sections.title || "",
sections.description || "",
sections.metadata || "",
sections.comments || "",
sections.julesRules || "",
].join("");
}
// For PR + Linear combined format (existing logic)
const fullBranchName =
prDetails?.head_ref || linearInfo?.branchName || "unknown";
// Header section
if (config.output.includeBranchNameHeader) {
sections.header = `${fullBranchName}\n\n`;
}
// Summary header
if (config.output.includeUnifiedSummaryHeader) {
sections.summaryHeader = `**UNIFIED PR/ISSUE DISCUSSION SUMMARY**\n\n`;
}
// PR Overview
if (prInfo && prDetails && config.integrations.github.includePRDescription) {
let prOverview = `**GitHub PR Overview**\n`;
prOverview += `Title: ${prDetails.title}\n`;
prOverview += `Branch: ${prDetails.head_ref} → ${prDetails.base_ref}\n`;
prOverview += `State: ${prDetails.state}${
prDetails.draft ? " (Draft)" : ""
}\n`;
prOverview += `URL: https://github.com/${prInfo.owner}/${prInfo.repo}/pull/${prInfo.number}\n`;
if (prDetails.body) {
prOverview += `Description: ${prDetails.body}\n`;
}
prOverview += `\n`;
sections.prOverview = prOverview;
}
// Linear Overview
if (linearInfo && config.integrations.linear.includeComments) {
let linearOverview = `**Linear Issue Context**\n`;
linearOverview += `ID: ${linearInfo.id}\n`;
linearOverview += `Title: ${linearInfo.title}\n`;
if (linearInfo.description) {
linearOverview += `Description: ${linearInfo.description}\n`;
}
if (linearInfo.branchName) {
linearOverview += `Branch: ${linearInfo.branchName}\n`;
}
if (linearInfo.comments.length > 0) {
linearOverview += `\nLinear Discussion:\n`;
linearInfo.comments.forEach((comment) => {
linearOverview += `**${comment.user.name}:** ${comment.body}\n`;
});
}
linearOverview += `\n`;
sections.linearOverview = linearOverview;
}
// Process and enhance all comments
const allReviews: Comment[] = reviews.map((review) => ({
...review,
isBot: getBotUsers().includes(review.author),
priority: detectPriority(review.body),
}));
const allReviewComments: Comment[] = reviewComments.map((comment) => ({
...comment,
isBot: getBotUsers().includes(comment.author),
priority: detectPriority(comment.body),
}));
const allIssueComments: Comment[] = issueComments.map((comment) => ({
...comment,
isBot: getBotUsers().includes(comment.author),
priority: detectPriority(comment.body),
}));
// Deduplicate and separate human vs bot comments
const humanReviews = deduplicateComments(allReviews.filter((r) => !r.isBot));
const botReviews = deduplicateComments(allReviews.filter((r) => r.isBot));
const humanReviewComments = deduplicateComments(
allReviewComments.filter((c) => !c.isBot)
);
const botReviewComments = deduplicateComments(
allReviewComments.filter((c) => c.isBot)
);
const humanIssueComments = deduplicateComments(
allIssueComments.filter((c) => !c.isBot)
);
const botIssueComments = deduplicateComments(
allIssueComments.filter((c) => c.isBot)
);
// Human reviews section (prioritized first)
if (humanReviews.length > 0 && config.integrations.github.includeReviews) {
let humanReviewsSection = `${config.display.customHeaders.humanReviews}\n\n`;
const sortedReviews = humanReviews.sort((a, b) => {
const priorityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
return priorityOrder[a.priority!] - priorityOrder[b.priority!];
});
sortedReviews.forEach((review, index) => {
const priorityEmoji = config.priority.priorityEmojis[review.priority!];
humanReviewsSection += `**Review ${index + 1}** ${priorityEmoji} ${
review.priority
}\n`;
humanReviewsSection += `**Reviewer:** ${review.author}\n`;
humanReviewsSection += `**State:** ${review.state}\n`;
humanReviewsSection += `**Comment:** ${review.body}\n`;
humanReviewsSection += `\n`;
});
sections.humanReviews = humanReviewsSection;
}
// Human inline code comments (prioritized)
if (
humanReviewComments.length > 0 &&
config.integrations.github.includeReviewComments
) {
let codeCommentsSection = `${config.display.customHeaders.humanCodeComments}\n\n`;
if (config.codeContext.groupCommentsByFile) {
// Group by file for better organization
const commentsByFile = humanReviewComments.reduce((acc, comment) => {
const file = comment.path || "General";
if (!acc[file]) acc[file] = [];
acc[file].push(comment);
return acc;
}, {} as Record<string, Comment[]>);
Object.entries(commentsByFile).forEach(([file, comments]) => {
if (config.codeContext.showFilePaths) {
codeCommentsSection += `**File: ${file}**\n`;
}
comments
.sort((a, b) => {
const priorityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
return priorityOrder[a.priority!] - priorityOrder[b.priority!];
})
.forEach((comment, index) => {
const priorityEmoji =
config.priority.priorityEmojis[comment.priority!];
codeCommentsSection += `**Comment ${index + 1}** ${priorityEmoji} ${
comment.priority
}\n`;
if (comment.line && config.codeContext.showLineNumbers) {
codeCommentsSection += `**Line:** ${comment.line}\n`;
}
codeCommentsSection += `**Reviewer:** ${comment.author}\n`;
codeCommentsSection += `**Comment:** ${comment.body}\n`;
if (comment.diff_hunk) {
codeCommentsSection += `**Code Context:**\n\`\`\`\n${truncateCodeContext(
comment.diff_hunk
)}\n\`\`\`\n`;
}
codeCommentsSection += `\n`;
});
});
} else {
// Simple list without grouping
humanReviewComments
.sort((a, b) => {
const priorityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
return priorityOrder[a.priority!] - priorityOrder[b.priority!];
})
.forEach((comment, index) => {
const priorityEmoji =
config.priority.priorityEmojis[comment.priority!];
codeCommentsSection += `**Comment ${index + 1}** ${priorityEmoji} ${
comment.priority
}\n`;
if (config.codeContext.showFilePaths && comment.path) {
codeCommentsSection += `**File:** ${comment.path}\n`;
}
if (comment.line && config.codeContext.showLineNumbers) {
codeCommentsSection += `**Line:** ${comment.line}\n`;
}
codeCommentsSection += `**Reviewer:** ${comment.author}\n`;
codeCommentsSection += `**Comment:** ${comment.body}\n`;
if (comment.diff_hunk) {
codeCommentsSection += `**Code Context:**\n\`\`\`\n${truncateCodeContext(
comment.diff_hunk
)}\n\`\`\`\n`;
}
codeCommentsSection += `\n`;
});
}
sections.humanCodeComments = codeCommentsSection;
}
// Human general comments
if (
humanIssueComments.length > 0 &&
config.integrations.github.includeIssueComments
) {
let generalCommentsSection = `${config.display.customHeaders.humanGeneralComments}\n\n`;
const sortedComments = humanIssueComments.sort((a, b) => {
const priorityOrder = { HIGH: 0, MEDIUM: 1, LOW: 2 };
return priorityOrder[a.priority!] - priorityOrder[b.priority!];
});
sortedComments.forEach((comment, index) => {
const priorityEmoji = config.priority.priorityEmojis[comment.priority!];
generalCommentsSection += `**Comment ${index + 1}** ${priorityEmoji} ${
comment.priority
}\n`;
generalCommentsSection += `**Author:** ${comment.author}\n`;
generalCommentsSection += `**Comment:** ${comment.body}\n`;
generalCommentsSection += `**Posted:** ${comment.created_at}\n`;
generalCommentsSection += `\n`;
});
sections.humanGeneralComments = generalCommentsSection;
}
// Bot comments section (collapsed/summarized)
const totalBotComments =
botReviews.length + botReviewComments.length + botIssueComments.length;
if (totalBotComments > 0 && config.filtering.includeBotFeedback) {
let botFeedbackSection = `${config.display.customHeaders.botFeedback} (${totalBotComments} items)\n\n`;
// Summarize bot feedback by type and priority
const botSummary = {
HIGH: [] as string[],
MEDIUM: [] as string[],
LOW: [] as string[],
};
[...botReviews, ...botReviewComments, ...botIssueComments].forEach(
(comment) => {
const summary = `${comment.author}: ${comment.body.substring(
0,
100
)}...`;
botSummary[comment.priority!].push(summary);
}
);
Object.entries(botSummary).forEach(([priority, items]) => {
if (items.length > 0) {
const emoji =
config.priority.priorityEmojis[
priority as keyof typeof config.priority.priorityEmojis
];
botFeedbackSection += `**${priority} Priority ${emoji}** (${items.length} items)\n`;
items
.slice(0, config.filtering.maxBotItemsPerPriority)
.forEach((item) => (botFeedbackSection += `• ${item}\n`));
if (items.length > config.filtering.maxBotItemsPerPriority) {
botFeedbackSection += `• ... and ${
items.length - config.filtering.maxBotItemsPerPriority
} more\n`;
}
botFeedbackSection += `\n`;
}
});
sections.botFeedback = botFeedbackSection;
}
// Enhanced action items summary
const totalHumanComments =
humanReviews.length +
humanReviewComments.length +
humanIssueComments.length;
const highPriorityCount = [
...humanReviews,
...humanReviewComments,
...humanIssueComments,
].filter((c) => c.priority === "HIGH").length;
const mediumPriorityCount = [
...humanReviews,
...humanReviewComments,
...humanIssueComments,
].filter((c) => c.priority === "MEDIUM").length;
if (totalHumanComments > 0) {
let actionItemsSection = `${config.display.customHeaders.actionItems}\n`;
if (highPriorityCount > 0)
actionItemsSection += `${config.priority.priorityEmojis.HIGH} **URGENT**: ${highPriorityCount} high priority items requiring immediate attention\n`;
if (mediumPriorityCount > 0)
actionItemsSection += `${config.priority.priorityEmojis.MEDIUM} **IMPORTANT**: ${mediumPriorityCount} medium priority improvements\n`;
actionItemsSection += `📊 **TOTAL**: ${totalHumanComments} human feedback items + ${totalBotComments} bot suggestions\n`;
actionItemsSection += `\n`;
sections.actionItems = actionItemsSection;
}
// Jules Rules
if (config.output.includeJulesRules) {
let rulesSection = `${config.display.customHeaders.julesRules}\n`;
// Use custom rules if provided, otherwise use default
const rulesToUse =
config.output.customJulesRules.length > 0
? config.output.customJulesRules
: [
"You don't have access to project environmental variables. If you must make an edit / migration of the database please instead edit prisma schema file and leave it as is, assuming a human will migrate it later.",
`When publishing the github branch the name MUST BE THE FULL EXACT the FROM branch mentioned at the top of the output (but not the shortened version at the top of the output). For example it will be the full: "${fullBranchName}"`,
];
rulesToUse.forEach((rule) => {
rulesSection += `- ${rule}\n`;
});
// Add additional rules
config.output.additionalJulesRules.forEach((rule) => {
rulesSection += `- ${rule}\n`;
});
sections.julesRules = rulesSection;
}
// Build output according to configured section order
for (const sectionName of config.output.sectionOrder) {
if (sections[sectionName]) {
if (
config.filtering.includeEmptySections ||
sections[sectionName].trim()
) {
output += sections[sectionName];
}
}
}
return output;
}
async function processInput(input: string): Promise<ExtractedData> {
const extractedData: ExtractedData = {
prInfo: null,
linearInfo: null,
reviews: [],
reviewComments: [],
issueComments: [],
};
let foundSomething = false;
// Determine if input looks like Linear ID or PR number
const isLinearId = /[A-Z]/.test(input);
if (isLinearId) {
logInfo(`🔍 Processing as Linear issue: ${input}`);
// Try to fetch Linear issue
const linearInfo = await fetchLinearIssue(input);
if (linearInfo) {
extractedData.linearInfo = linearInfo;
foundSomething = true;
// Try to find associated PR
let prInfo = await findPRFromLinearAttachments(linearInfo);
if (!prInfo && linearInfo.branchName) {
prInfo = await findPRFromBranch(linearInfo.branchName);
}
if (prInfo) {
extractedData.prInfo = prInfo;
const prData = await fetchPRData(prInfo);
if (prData) {
extractedData.prDetails = prData.prDetails;
extractedData.reviews = prData.reviews;
extractedData.reviewComments = prData.reviewComments;
extractedData.issueComments = prData.issueComments;
}
} else {
// Only prompt for GitHub PR number if not running in no-clipboard-output mode
if (!suppressLogs && config.workflow.enableInteractivePrompts) {
logInfo("\n📎 No GitHub PR found attached to this Linear issue.");
const prNumber = await promptUser(
"🤔 Enter GitHub PR number (or press Enter to skip): "
);
if (prNumber) {
try {
const { owner, repo } = await getCurrentRepoInfo();
const prInfo = { owner, repo, number: parseInt(prNumber) };
const prData = await fetchPRData(prInfo);
if (prData) {
extractedData.prInfo = prInfo;
extractedData.prDetails = prData.prDetails;
extractedData.reviews = prData.reviews;
extractedData.reviewComments = prData.reviewComments;
extractedData.issueComments = prData.issueComments;
}
} catch (error) {
logWarning(`Could not fetch PR ${prNumber}`);
}
}
}
// When suppressLogs is true (--no-clipboard-output), just continue with Linear-only data
}
}
} else {
logInfo(`🔍 Processing as GitHub PR: ${input}`);
// Try to fetch GitHub PR
try {
const { owner, repo } = await getCurrentRepoInfo();
const prInfo = { owner, repo, number: parseInt(input) };
const prData = await fetchPRData(prInfo);
if (prData) {
extractedData.prInfo = prInfo;
extractedData.prDetails = prData.prDetails;
extractedData.reviews = prData.reviews;
extractedData.reviewComments = prData.reviewComments;
extractedData.issueComments = prData.issueComments;
foundSomething = true;
// Try to find Linear issue from branch name
const branchName = prData.prDetails?.head_ref;
if (branchName) {
const linearMatch = branchName.match(/([A-Z]{2,10}-\d+)/);
if (linearMatch) {
const linearId = linearMatch[1];
const linearInfo = await fetchLinearIssue(linearId);
if (linearInfo) {
extractedData.linearInfo = linearInfo;
}
}
}
if (
!extractedData.linearInfo &&
!suppressLogs &&
config.workflow.enableInteractivePrompts
) {
// Only prompt for Linear issue ID if not running in no-clipboard-output mode
logInfo("\n📎 No Linear issue found for this PR branch.");
const linearId = await promptUser(
"🤔 Enter Linear issue ID (or press Enter to skip): "
);
if (linearId) {
const linearInfo = await fetchLinearIssue(linearId);
if (linearInfo) {
extractedData.linearInfo = linearInfo;
}
}
}
}
} catch (error) {
logWarning(`Could not fetch GitHub PR ${input}`);
}
}
if (!foundSomething) {
throw new Error(
`Could not find data for ${input}. Make sure the ID/number is correct and you have access.`
);
}
return extractedData;
}
async function julesMode(extractedData: ExtractedData): Promise<void> {
// Check if this is a Linear-only issue (no PR found)
if (extractedData.linearInfo && !extractedData.prInfo) {
// For Linear-only issues, determine what to copy first based on config
let firstCopyContent = "";
switch (config.julesMode.linearOnlyFirstCopy) {
case "linear_id":
firstCopyContent = extractedData.linearInfo.id;
break;
case "title":
firstCopyContent = extractedData.linearInfo.title;
break;
case "branch_name":
firstCopyContent =
extractedData.linearInfo.branchName || extractedData.linearInfo.id;
break;
default:
firstCopyContent = extractedData.linearInfo.id;
}
try {
if (config.clipboard.enabled) {
clipboardy.writeSync(firstCopyContent);
logSuccess(
`📋 Step 1: ${config.julesMode.linearOnlyFirstCopy.replace(
"_",
" "
)} copied to clipboard: ${firstCopyContent}`
);
} else {
console.log(
`\n🔖 ${config.julesMode.linearOnlyFirstCopy.replace(
"_",
" "
)}: ${firstCopyContent}\n`
);
}
// Wait for user to press any key
await promptUser(config.julesMode.prompts.firstCopyComplete);
// Now copy the full discussion
const fullOutput = formatOutput(extractedData);
if (config.clipboard.enabled) {
clipboardy.writeSync(fullOutput);
logSuccess(config.julesMode.prompts.secondCopyComplete);
} else {
console.log("\n" + "=".repeat(config.display.separatorWidth));
console.log("📋 FULL DISCUSSION:");
console.log("=".repeat(config.display.separatorWidth));
console.log(fullOutput);
console.log("=".repeat(config.display.separatorWidth));
}
} catch (error) {
if (config.clipboard.fallbackToConsole) {
logWarning(
"Could not access clipboard, falling back to console output"
);
console.log(
`\n🔖 ${config.julesMode.linearOnlyFirstCopy.replace(
"_",
" "
)}: ${firstCopyContent}\n`
);
await promptUser("Press Enter to continue...");
console.log("\n" + "=".repeat(config.display.separatorWidth));
console.log("📋 FULL DISCUSSION:");
console.log("=".repeat(config.display.separatorWidth));
console.log(formatOutput(extractedData));
console.log("=".repeat(config.display.separatorWidth));
} else {
throw error;
}
}
} else {
// For PRs (with or without Linear issues), determine what to copy first
let firstCopyContent = "";
switch (config.julesMode.firstCopy) {
case "branch_name":
firstCopyContent =
extractedData.prDetails?.head_ref ||
extractedData.linearInfo?.branchName ||
"unknown-branch";
break;
case "linear_id":
firstCopyContent =
extractedData.linearInfo?.id ||
extractedData.prDetails?.head_ref ||
"unknown";
break;
case "title":
firstCopyContent =
extractedData.prDetails?.title ||
extractedData.linearInfo?.title ||
"unknown-title";
break;
default:
firstCopyContent =
extractedData.prDetails?.head_ref ||
extractedData.linearInfo?.branchName ||
"unknown-branch";
}
try {
if (config.clipboard.enabled) {
clipboardy.writeSync(firstCopyContent);
logSuccess(
`📋 Step 1: ${config.julesMode.firstCopy.replace(
"_",
" "
)} copied to clipboard: ${firstCopyContent}`
);
} else {
console.log(
`\n🔖 ${config.julesMode.firstCopy.replace(
"_",
" "
)}: ${firstCopyContent}\n`
);
}
// Wait for user to press any key
await promptUser(config.julesMode.prompts.firstCopyComplete);
// Now copy the full discussion
const fullOutput = formatOutput(extractedData);
if (config.clipboard.enabled) {
clipboardy.writeSync(fullOutput);
logSuccess(config.julesMode.prompts.secondCopyComplete);
} else {
console.log("\n" + "=".repeat(config.display.separatorWidth));
console.log("📋 FULL DISCUSSION:");
console.log("=".repeat(config.display.separatorWidth));
console.log(fullOutput);
console.log("=".repeat(config.display.separatorWidth));
}
} catch (error) {
if (config.clipboard.fallbackToConsole) {
logWarning(
"Could not access clipboard, falling back to console output"
);
console.log(
`\n🔖 ${config.julesMode.firstCopy.replace(
"_",
" "
)}: ${firstCopyContent}\n`
);
await promptUser("Press Enter to continue...");
console.log("\n" + "=".repeat(config.display.separatorWidth));
console.log("📋 FULL DISCUSSION:");
console.log("=".repeat(config.display.separatorWidth));
console.log(formatOutput(extractedData));
console.log("=".repeat(config.display.separatorWidth));
} else {
throw error;
}
}
}
}
async function main() {
try {
const args = normalizeArgs(process.argv.slice(2));
if (args.includes("--help") || args.includes("-h")) {
console.log(`
🔧 **Unified PR/Issue Discussion Extractor**
USAGE:
npm run extract-pr <PR_NUMBER|LINEAR_ID> [options]
EXAMPLES:
npm run extract-pr 123 Extract GitHub PR #123 (+ find Linear issue)
npm run extract-pr GRE-456 Extract Linear issue GRE-456 (+ find GitHub PR)
OPTIONS:
-j, --jules Jules mode: Copy branch name first (or Linear issue ID if no PR), then full discussion
-s, --summary Generate AI summary using Gemini
--no-clipboard-output Suppress clipboard operations and debug output (for script usage)
--help Show this help
FEATURES:
🔗 **Unified Extraction**: Automatically finds connected Linear issues and GitHub PRs
🤝 **Interactive Prompting**: If missing data, prompts for the other platform's ID
🤖 **AI Summaries**: Uses Gemini AI to generate actionable summaries
👑 **Jules Mode**: Two-step clipboard copying for streamlined workflow
📊 **Smart Prioritization**: HIGH/MEDIUM/LOW priority classification
🔄 **Bot Filtering**: Separates human feedback from bot suggestions
ENVIRONMENT:
LINEAR_API_KEY - Required for Linear integration
GEMINI_API_KEY - Required for AI summaries
RATE LIMITS (Gemini):
RPM: 10 | TPM: 250,000 | RDP: 500
`);
closeReadlineInterface();
process.exit(0);
}
if (args.length === 0) {
logError("Usage: npm run extract-pr <PR_NUMBER|LINEAR_ISSUE_ID>");
logError("Examples:");
logError(" npm run extract-pr 123 # GitHub PR number");
logError(" npm run extract-pr GRE-456 # Linear issue ID");
logError(" npm run extract-pr --help # Show this help");
closeReadlineInterface();
process.exit(1);
}
const input = args.find((arg) => !arg.startsWith("-")) || args[0];
const julesFlag = args.includes("-j") || args.includes("--jules");
const summaryFlag = args.includes("-s") || args.includes("--summary");
const noClipboardOutput = args.includes("--no-clipboard-output");
// Set global flag to suppress logs when called by another script
suppressLogs = noClipboardOutput;
const startTime = Date.now();
logInfo(`🚀 Starting unified extraction for: ${input}`);
// Process the input and extract all data
const extractedData = await processInput(input);
// Handle Jules mode
if (julesFlag) {
await julesMode(extractedData);
closeReadlineInterface();
return;
}
// Generate the formatted output
const output = formatOutput(extractedData);
// Generate AI summary if requested
let summary = "";
if (summaryFlag) {
logInfo("🤖 Generating AI summary with Gemini...");
summary = await generateAISummary(extractedData);
}
const processingTime = Date.now() - startTime;
// Only show full output and clipboard operations if not suppressed
if (!noClipboardOutput) {
// Output the result
console.log("\n" + "=".repeat(config.display.separatorWidth));
console.log(output);
if (summary) {
console.log("=".repeat(30) + " AI SUMMARY " + "=".repeat(30));
console.log(summary);
}
console.log("=".repeat(config.display.separatorWidth));
// Copy to clipboard (full output + summary if available)
if (config.clipboard.enabled) {
try {
const clipboardContent = summary
? output +
"\n\n" +
"=".repeat(30) +
" AI SUMMARY " +
"=".repeat(30) +
"\n" +
summary
: output;
// Output what we're trying to copy to help debug clipboard issues
if (config.clipboard.showClipboardContent) {
console.log("\n" + "=".repeat(config.display.separatorWidth));
console.log("📋 CONTENT BEING COPIED TO CLIPBOARD:");
console.log("=".repeat(config.display.separatorWidth));
console.log(clipboardContent);
console.log("=".repeat(config.display.separatorWidth));
}
clipboardy.writeSync(clipboardContent);
logSuccess(
`✅ Output copied to clipboard!${
config.display.showProcessingTime
? ` (Processed in ${processingTime}ms)`
: ""
}`
);
} catch (error) {
if (config.clipboard.fallbackToConsole) {
logWarning(
"Could not copy to clipboard, but output is shown above"
);
} else {
throw error;
}
}
} else {
logSuccess(
`✨ Content extracted successfully!${
config.display.showProcessingTime
? ` (Processed in ${processingTime}ms)`
: ""
}`
);
}
} else {
// When called by another script, just output the content for capture
const finalOutput = summary
? output +
"\n\n" +
"=".repeat(30) +
" AI SUMMARY " +
"=".repeat(30) +
"\n" +
summary
: output;
console.log(finalOutput);
}
closeReadlineInterface();
} catch (error) {
logError(`Script failed: ${error}`);
closeReadlineInterface();
process.exit(1);
}
}
if (require.main === module) {
main();
}