src/github/junie/new-prompt-formatter.ts (259 lines of code) (raw):
import {
FetchedData,
GraphQLCommitNode,
GraphQLFileNode,
GraphQLReviewNode,
GraphQLTimelineItemNode,
isCrossReferencedEventNode,
isIssueCommentNode,
isReferencedEventNode
} from "../api/queries";
import {
isIssueCommentEvent,
isIssuesEvent,
isJiraWorkflowDispatchEvent,
isPullRequestEvent,
isPullRequestReviewCommentEvent,
isPullRequestReviewEvent,
isPushEvent,
isTriggeredByUserInteraction,
JiraIssuePayload,
JunieExecutionContext
} from "../context";
import {downloadJiraAttachmentsAndRewriteText} from "./attachment-downloader";
import {sanitizeContent} from "../../utils/sanitizer";
export class NewGitHubPromptFormatter {
async generatePrompt(context: JunieExecutionContext, fetchedData: FetchedData, userPrompt?: string, attachGithubContextToCustomPrompt: boolean = true) {
// If user provided custom prompt and doesn't want GitHub context, sanitize and return it
if (userPrompt && !attachGithubContextToCustomPrompt) {
return sanitizeContent(userPrompt);
}
// Handle Jira issue integration
if (isJiraWorkflowDispatchEvent(context)) {
return await this.generateJiraPrompt(context);
}
const repositoryInfo = this.getRepositoryInfo(context);
const actorInfo = this.getActorInfo(context);
const userInstruction = this.getUserInstruction(context, userPrompt)
const prOrIssueInfo = this.getPrOrIssueInfo(context, fetchedData);
const commitsInfo = this.getCommitsInfo(fetchedData);
const timelineInfo = this.getTimelineInfo(fetchedData);
const reviewsInfo = this.getReviewsInfo(fetchedData);
const changedFilesInfo = this.getChangedFilesInfo(fetchedData);
// Build the final prompt
const prompt = `You were triggered as a GitHub AI Assistant by ${context.eventName} action. Your task is to:
${userInstruction ? userInstruction : ""}
${repositoryInfo ? repositoryInfo : ""}
${prOrIssueInfo ? prOrIssueInfo : ""}
${commitsInfo ? commitsInfo : ""}
${timelineInfo ? timelineInfo : ""}
${reviewsInfo ? reviewsInfo : ""}
${changedFilesInfo ? changedFilesInfo : ""}
${actorInfo ? actorInfo : ""}
`;
// Sanitize the entire prompt once to prevent prompt injection attacks
// This removes HTML comments, invisible characters, obfuscated entities, etc.
return sanitizeContent(prompt);
}
private async generateJiraPrompt(context: JunieExecutionContext): Promise<string> {
const jira = context.payload as JiraIssuePayload;
// Format comments
const commentsInfo = jira.comments.length > 0
? '\n\nComments:\n' + jira.comments.map(comment => {
const date = new Date(comment.created).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return `[${date}] ${comment.author}:\n${comment.body}`;
}).join('\n\n')
: '';
// Form the complete prompt text
const promptText = `You were triggered as a GitHub AI Assistant by a Jira issue. Your task is to implement the requested feature or fix based on the Jira issue details below.
<jira_issue>
Issue Key: ${jira.issueKey}
Summary: ${jira.issueSummary}
Description: ${jira.issueDescription}${commentsInfo}
</jira_issue>
`;
// Download all attachments referenced in text (single pass), then sanitize
const promptWithAttachments = await downloadJiraAttachmentsAndRewriteText(promptText, jira.attachments);
return sanitizeContent(promptWithAttachments);
}
private getUserInstruction(context: JunieExecutionContext, customPrompt?: string): string | undefined {
let githubUserInstruction
if (isPullRequestEvent(context)) {
githubUserInstruction = context.payload.pull_request.body
} else if (isPullRequestReviewEvent(context)) {
githubUserInstruction = context.payload.review.body
} else if (isPullRequestReviewCommentEvent(context)) {
githubUserInstruction = context.payload.comment.body
} else if (isIssuesEvent(context)) {
githubUserInstruction = context.payload.issue.body
} else if (isIssueCommentEvent(context)) {
githubUserInstruction = context.payload.comment.body
}
const instruction = customPrompt || githubUserInstruction;
return instruction ? `
<user_instruction>
${instruction}
</user_instruction>` : undefined
}
private getPrOrIssueInfo(context: JunieExecutionContext, fetchedData: FetchedData): string | undefined {
if (context.isPR) {
const prInfo = this.getPrInfo(fetchedData);
return prInfo ? `<pull_request_info>\n${prInfo}\n</pull_request_info>` : undefined;
} else if (isTriggeredByUserInteraction(context) && !isPushEvent(context)) {
const issueInfo = this.getIssueInfo(fetchedData);
return issueInfo ? `<issue_info>\n${issueInfo}\n</issue_info>` : undefined;
}
return undefined
}
private getPrInfo(fetchedData: FetchedData): string {
const pr = fetchedData.pullRequest;
if (!pr) return "";
return `PR Number: #${pr.number}
Title: ${pr.title}
Author: @${pr.author?.login}
State: ${pr.state}
Branch: ${pr.headRefName} -> ${pr.baseRefName}
Base Commit: ${pr.baseRefOid}
Head Commit: ${pr.headRefOid}
Stats: +${pr.additions}/-${pr.deletions} (${pr.changedFiles} files, ${pr.commits.totalCount} commits)`
}
private getIssueInfo(fetchedData: FetchedData): string {
const issue = fetchedData.issue;
if (!issue) return "";
return `Issue Number: #${issue.number}
Title: ${issue.title}
Author: @${issue.author?.login}
State: ${issue.state}`
}
private getCommitsInfo(fetchedData: FetchedData): string | undefined {
const commits = fetchedData.pullRequest?.commits?.nodes;
if (!commits || commits.length === 0) {
return undefined;
}
const commitsInfo = this.formatCommits(commits);
return commitsInfo ? `<commits>\n${commitsInfo}\n</commits>` : undefined;
}
private formatCommits(commits: GraphQLCommitNode[]): string {
return commits.map(({commit}) => {
const shortHash = commit.oid.substring(0, 7);
const message = commit.messageHeadline || commit.message || 'No message';
const date = commit.committedDate || '';
return `[${date}] ${shortHash} - ${message}`;
}).join('\n');
}
private getTimelineInfo(fetchedData: FetchedData): string | undefined {
const timelineItems = fetchedData.issue?.timelineItems?.nodes || fetchedData.pullRequest?.timelineItems?.nodes;
if (!timelineItems || timelineItems.length === 0) {
return undefined;
}
const timelineInfo = this.formatTimelineItems(timelineItems);
return timelineInfo ? `<timeline>${timelineInfo}</timeline>` : undefined
}
private formatTimelineItems(timelineNodes: GraphQLTimelineItemNode[]): string {
const eventTexts: string[] = [];
for (const node of timelineNodes) {
let eventText: string | null = null;
if (isIssueCommentNode(node)) {
const author = node.author?.login;
const body = node.body;
const createdAt = node.createdAt;
eventText = `[${createdAt}] Comment by @${author}:
${body}`;
} else if (isReferencedEventNode(node)) {
const commitId = node.commit?.oid;
if (commitId) {
const hash = commitId.substring(0, 7);
const message = node.commit?.message;
const createdAt = node.createdAt;
eventText = `[${createdAt}] Referenced commit ${hash}${message ? `: ${message}` : ''}`;
}
} else if (isCrossReferencedEventNode(node)) {
const source = node.source;
if (source) {
const createdAt = node.createdAt;
const isPullRequest = source.__typename === 'PullRequest';
const type = isPullRequest ? 'PR' : 'Issue';
eventText = `[${createdAt}] Cross-referenced from ${type} #${source.number}: ${source.title}`;
}
}
if (eventText) {
eventTexts.push(eventText);
}
}
return eventTexts.join('\n\n');
}
private getReviewsInfo(fetchedData: FetchedData): string | undefined {
const reviews = fetchedData.pullRequest?.reviews?.nodes;
if (!reviews || reviews.length === 0) {
return undefined;
}
const reviewsInfo = this.formatReviews(reviews);
return reviewsInfo ? `<reviews>${reviewsInfo}</reviews>` : undefined
}
private formatReviews(reviews: GraphQLReviewNode[]): string {
const reviewTexts: string[] = [];
for (const review of reviews) {
const reviewText = this.formatReview(review);
if (reviewText.trim()) {
reviewTexts.push(reviewText);
}
}
if (reviewTexts.length === 0) {
return '';
}
return reviewTexts.join('\n\n---\n\n');
}
private formatReview(review: GraphQLReviewNode): string {
const author = review.author?.login;
const state = review.state;
const submittedAt = review.submittedAt;
const body = review.body;
let reviewText = `[${submittedAt}] Review by @${author} (${state})`;
if (body) {
reviewText += `\n${body}`;
}
if (review.comments.nodes.length > 0) {
reviewText += '\n\nReview Comments:';
const sortedComments = [...review.comments.nodes].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
for (const comment of sortedComments) {
const commentAuthor = comment.author?.login;
const commentBody = comment.body;
const path = comment.path;
const diffHunk = comment.diffHunk;
reviewText += `\n\n ${path}:`;
if (diffHunk) {
reviewText += `\n \`\`\`diff\n${diffHunk}\n \`\`\``;
}
reviewText += `\n @${commentAuthor}: ${commentBody}`;
}
}
return reviewText;
}
private getChangedFilesInfo(fetchedData: FetchedData): string | undefined {
const files = fetchedData.pullRequest?.files?.nodes;
if (!files || files.length === 0) {
return undefined;
}
const changedFilesInfo = this.formatChangedFiles(files);
return changedFilesInfo ? `<changed_files>${changedFilesInfo}</changed_files>` : undefined
}
private formatChangedFiles(files: GraphQLFileNode[]): string {
return files.map(file => {
const changeType = file.changeType.toLowerCase();
return `${file.path} (${changeType}) +${file.additions}/-${file.deletions}`;
}).join('\n');
}
private getRepositoryInfo(context: JunieExecutionContext) {
const repo = context.payload.repository;
return `<repository>
Repository: ${repo.full_name}
Owner: ${repo.owner.login}
</repository>`
}
private getActorInfo(context: JunieExecutionContext) {
return `<actor>
Triggered by: @${context.actor}
Event: ${context.eventName}${context.eventAction ? ` (${context.eventAction})` : ""}
</actor>`
}
}