/**
* Data mappers to normalize GitHub GraphQL responses to MCP DTOs
*
* Maps GitHub GraphQL API objects to normalized DTOs for consistent MCP tool responses.
* Reference: https://docs.github.com/en/graphql/reference/objects
*/
import { validateTimestamp } from '../utils/time.js';
// MCP DTOs - Normalized data structures for MCP tools
export interface AuthoredPR {
id: string;
repo: string;
title: string;
createdAt: string;
mergedAt: string | null;
state: 'OPEN' | 'MERGED' | 'CLOSED';
filesChanged: number;
additions: number;
deletions: number;
}
export interface PRReview {
id: string;
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED';
prId: string;
prNumber: number;
prTitle: string;
prRepo: string;
submittedAt: string;
}
export interface ReviewComment {
id: string;
body: string;
filePath: string | null;
lineNumber: number | null;
prId: string;
prNumber: number;
prTitle: string;
prRepo: string;
prUrl: string;
prCreatedAt: string;
timestamp: string;
reviewId: string;
}
export interface CommentImpact {
commentId: string;
prId: string;
hadImpact: boolean;
confidence: number;
evidence: string[];
}
export interface CommentImpactResponse {
impacts: CommentImpact[];
stats?: {
totalComments: number;
totalPRsReviewed: number;
totalImpacts: number;
};
}
export interface UserComment {
id: string;
body: string;
createdAt: string;
author: string;
prId: string;
prNumber: number;
prTitle: string;
prRepo: string;
commentType: 'review' | 'issue';
filePath: string | null;
lineNumber: number | null;
reviewId: string | null;
}
export interface UserRepoStats {
username: string;
repo: string;
timeRange: {
from: string;
to: string;
};
prs: {
count: number;
merged: number;
open: number;
closed: number;
};
comments: {
total: number;
review: number;
issue: number;
};
reviews: {
total: number;
totalPRsReviewed: number;
approved: number;
changesRequested: number;
commented: number;
};
codeChanges: {
filesChanged: number;
additions: number;
deletions: number;
netChange: number;
};
}
export interface PRReviewComments {
prId: string;
prNumber: number;
prTitle: string;
prRepo: string;
prUrl: string;
prCreatedAt: string;
comments: string[];
totalComments: number;
}
export interface ReviewCommentsResponse {
userId: string;
dateRange: {
from: string;
to: string;
};
totalPRsReviewed: number;
totalComments: number;
prs: PRReviewComments[];
}
/**
* Maps GitHub PullRequest node to AuthoredPR DTO
*
* Reference: https://docs.github.com/en/graphql/reference/objects#pullrequest
*/
export function mapAuthoredPR(node: any): AuthoredPR {
// Map GitHub PR state to normalized state
const state = node.state === 'MERGED' ? 'MERGED' :
node.state === 'CLOSED' ? 'CLOSED' : 'OPEN';
return {
id: node.id,
repo: node.repository?.nameWithOwner || 'unknown',
title: node.title || '',
createdAt: validateTimestamp(node.createdAt),
mergedAt: node.mergedAt ? validateTimestamp(node.mergedAt) : null,
state,
filesChanged: node.changedFiles || node.files?.totalCount || 0,
additions: node.additions || 0,
deletions: node.deletions || 0,
};
}
/**
* Maps GitHub PullRequestReview contribution to PRReview DTO
*
* Reference:
* - https://docs.github.com/en/graphql/reference/objects#pullrequestreview
* - https://docs.github.com/en/graphql/reference/objects#pullrequestreviewcontribution
*/
export function mapPRReview(contribution: any): PRReview {
const review = contribution.pullRequestReview;
const pr = review.pullRequest;
// Map GitHub review state to our enum
// States: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING
let state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED';
switch (review.state) {
case 'APPROVED':
state = 'APPROVED';
break;
case 'CHANGES_REQUESTED':
state = 'CHANGES_REQUESTED';
break;
default:
state = 'COMMENTED';
}
return {
id: review.id,
state,
prId: pr.id,
prNumber: pr.number,
prTitle: pr.title || '',
prRepo: pr.repository?.nameWithOwner || 'unknown',
submittedAt: validateTimestamp(review.submittedAt),
};
}
/**
* Maps GitHub review comments to ReviewComment DTOs
*
* Extracts both general review body comments and inline review comments.
* Reference: https://docs.github.com/en/graphql/reference/objects#pullrequestreviewcomment
*/
export function mapReviewComments(contribution: any): ReviewComment[] {
const review = contribution.pullRequestReview;
const pr = review.pullRequest;
const comments: ReviewComment[] = [];
const prCreatedAt = pr.createdAt ? validateTimestamp(pr.createdAt) : '';
const prUrl = pr.url || '';
// Add general review comment if body exists
if (review.body && review.body.trim()) {
comments.push({
id: `${review.id}-body`,
body: review.body,
filePath: null,
lineNumber: null,
prId: pr.id,
prNumber: pr.number,
prTitle: pr.title || '',
prRepo: pr.repository?.nameWithOwner || 'unknown',
prUrl,
prCreatedAt,
timestamp: validateTimestamp(review.submittedAt),
reviewId: review.id,
});
}
// Add inline comments
if (review.comments?.nodes) {
for (const comment of review.comments.nodes) {
comments.push({
id: comment.id,
body: comment.body || '',
filePath: comment.path || null,
lineNumber: comment.line || null,
prId: pr.id,
prNumber: pr.number,
prTitle: pr.title || '',
prRepo: pr.repository?.nameWithOwner || 'unknown',
prUrl,
prCreatedAt,
timestamp: validateTimestamp(comment.createdAt),
reviewId: review.id,
});
}
}
return comments;
}
/**
* Analyzes comment impact based on PR timeline
*
* Heuristic: Checks if commits were made after a comment timestamp.
* Higher confidence if comment is on a specific file/line and that file was modified.
*
* Reference: https://docs.github.com/en/graphql/reference/objects#pullrequesttimelineitems
*/
export function analyzeCommentImpact(
comment: ReviewComment,
prTimeline: any
): CommentImpact {
const evidence: string[] = [];
let hadImpact = false;
let confidence = 0;
if (!prTimeline?.timelineItems?.nodes) {
// Return hadImpact: false with empty evidence - caller will filter these out
return {
commentId: comment.id,
prId: comment.prId,
hadImpact: false,
confidence: 0,
evidence: [],
};
}
const commentTime = new Date(comment.timestamp).getTime();
// Get commits from PR commits (more reliable than timeline items)
// Commits are ordered chronologically, so we can check if any came after the comment
const commits = prTimeline.commits?.nodes || [];
// Also check timeline items for commits (fallback)
const timelineCommits = (prTimeline.timelineItems?.nodes || []).filter(
(item: any) => item.__typename === 'PullRequestCommit'
);
// Combine both sources and deduplicate by commit ID
const allCommits = new Map<string, any>();
for (const commitNode of commits) {
if (commitNode?.commit?.id) {
allCommits.set(commitNode.commit.id, commitNode.commit);
}
}
for (const commitItem of timelineCommits) {
if (commitItem?.commit?.id) {
allCommits.set(commitItem.commit.id, commitItem.commit);
}
}
for (const commit of allCommits.values()) {
// Use committedDate if available (when commit was actually committed/pushed to branch)
// Otherwise fall back to authoredDate (when commit was originally authored)
// Note: authoredDate can be earlier than when commit was pushed to PR, so we prefer committedDate
const commitDateStr = commit.committedDate || commit.authoredDate;
if (!commitDateStr) continue;
const commitTime = new Date(commitDateStr).getTime();
// Skip commits that were made before or at the same time as the comment
// We want commits that happened AFTER the comment to show impact
// Using <= to skip commits at the same timestamp (allowing for small timing differences)
if (commitTime <= commentTime) {
continue;
}
// Found a commit after the comment - this indicates potential impact
// If comment is on a specific file/line, check if that file was modified
if (comment.filePath) {
// Check if file was in commit changes
// Note: GitHub GraphQL doesn't expose file-level commit details easily
// This is a simplified heuristic - higher confidence if files were changed
if (commit.changedFiles > 0) {
evidence.push(
`Commit ${commit.id.substring(0, 7)} modified files after comment (${commit.changedFiles} files)`
);
hadImpact = true;
confidence = 0.5; // Medium confidence without file-level detail
}
} else {
// General comment - lower confidence
if (commit.changedFiles > 0) {
evidence.push(
`General comment followed by commit ${commit.id.substring(0, 7)}`
);
hadImpact = true;
confidence = 0.3;
}
}
}
// Higher confidence if comment requested changes (would need additional query to verify)
if (hadImpact && comment.reviewId) {
confidence = Math.min(confidence + 0.2, 1.0);
}
// Only return impact if there was actual impact (commits found after comment)
// Don't include "No commits found" message - just return hadImpact: false
// The caller will filter these out
return {
commentId: comment.id,
prId: comment.prId,
hadImpact,
confidence,
evidence,
};
}
/**
* Maps review comment from PR review to UserComment DTO
*/
export function mapReviewCommentToUserComment(
comment: any,
pr: any,
reviewId: string | null
): UserComment {
return {
id: comment.id,
body: comment.body || '',
createdAt: validateTimestamp(comment.createdAt),
author: comment.author?.login || 'unknown',
prId: pr.id,
prNumber: pr.number,
prTitle: pr.title || '',
prRepo: pr.repository?.nameWithOwner || 'unknown',
commentType: 'review',
filePath: comment.path || null,
lineNumber: comment.line || null,
reviewId: reviewId,
};
}
/**
* Maps issue comment from PR to UserComment DTO
*/
export function mapIssueCommentToUserComment(
comment: any,
pr: any
): UserComment {
return {
id: comment.id,
body: comment.body || '',
createdAt: validateTimestamp(comment.createdAt),
author: comment.author?.login || 'unknown',
prId: pr.id,
prNumber: pr.number,
prTitle: pr.title || '',
prRepo: pr.repository?.nameWithOwner || 'unknown',
commentType: 'issue',
filePath: null,
lineNumber: null,
reviewId: null,
};
}