Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | 14x 16x 18x 5x 18x 12x 18x 16x 1x 15x 16x 5x 10x 10x 5x 5x 5x 5x 22x 22x 19x 2x 17x 1x 16x 3x 3x 13x 9x 13x 9x 22x 3x 3x 6x 6x 6x 6x 6x 6x 6x 6x 6x | import type { Octokit } from '@octokit/rest';
import type { ExistingComment } from './dedup.js';
import type { Finding, FileChange } from '../types/index.js';
import { generateContentHash } from './dedup.js';
/**
* Scope of analyzed files in the PR.
*/
export interface AnalyzedScope {
/** Set of file paths that were in the diff */
files: Set<string>;
}
/**
* Build the analyzed scope from file changes.
*/
export function buildAnalyzedScope(fileChanges: FileChange[]): AnalyzedScope {
return {
files: new Set(fileChanges.map((f) => f.filename)),
};
}
/**
* Check if a comment's file was in the analyzed scope.
* Only comments on files that were analyzed should be considered for resolution.
*/
export function isInAnalyzedScope(comment: ExistingComment, scope: AnalyzedScope): boolean {
return scope.files.has(comment.path);
}
/** Strip finding ID prefix like "[WRZ-XPL] " from a title */
function stripFindingIdPrefix(title: string): string {
return title.replace(/^\[[A-Z0-9]{3}-[A-Z0-9]{3}\]\s*/, '');
}
/**
* Check if a single location matches a comment (same path, proximate line).
*/
function locationMatchesComment(
location: { path: string; startLine: number; endLine?: number },
comment: ExistingComment
): boolean {
if (location.path !== comment.path) return false;
const line = location.endLine ?? location.startLine;
return Math.abs(line - comment.line) <= 5;
}
/**
* Check if a finding matches a comment (same location and similar content).
* Checks both the primary location and any additional locations.
*/
export function findingMatchesComment(finding: Finding, comment: ExistingComment): boolean {
// Must have a location to match
if (!finding.location) {
return false;
}
// Check if any location (primary or additional) matches the comment path+line
const locationMatches =
locationMatchesComment(finding.location, comment) ||
(finding.additionalLocations?.some((loc) => locationMatchesComment(loc, comment)) ?? false);
if (!locationMatches) {
return false;
}
// Check content hash for exact match
const findingHash = generateContentHash(finding.title, finding.description);
if (findingHash === comment.contentHash) {
return true;
}
// If hashes don't match exactly, check if the title is similar enough
// This handles cases where description might have minor changes
// Strip ID prefix (e.g. "[WRZ-XPL] ") from comment titles before comparing
const normalizedFindingTitle = finding.title.toLowerCase().trim();
const normalizedCommentTitle = stripFindingIdPrefix(comment.title).toLowerCase().trim();
return normalizedFindingTitle === normalizedCommentTitle;
}
/**
* Find comments that no longer have matching findings (stale comments).
* Only considers comments on files that were in the analyzed scope.
*/
export function findStaleComments(
existingComments: ExistingComment[],
allFindings: Finding[],
scope: AnalyzedScope
): ExistingComment[] {
const staleComments: ExistingComment[] = [];
for (const comment of existingComments) {
// Skip comments that don't have thread IDs (can't resolve them)
if (!comment.threadId) {
continue;
}
// Skip already-resolved comments (nothing to do)
if (comment.isResolved) {
continue;
}
// Comments on files NOT in scope are orphaned (file renamed, reverted, etc.)
if (!isInAnalyzedScope(comment, scope)) {
staleComments.push(comment);
continue;
}
// Check if any finding matches this comment
const hasMatchingFinding = allFindings.some((finding) =>
findingMatchesComment(finding, comment)
);
// If no matching finding, this comment is stale
if (!hasMatchingFinding) {
staleComments.push(comment);
}
}
return staleComments;
}
const RESOLVE_THREAD_MUTATION = `
mutation($threadId: ID!) {
resolveReviewThread(input: { threadId: $threadId }) {
thread {
id
isResolved
}
}
}
`;
/** Maximum stale comments to resolve per run (matches default maxFindings) */
const MAX_STALE_RESOLUTIONS = 50;
export interface ResolveResult {
resolvedCount: number;
resolvedIds: Set<number>;
}
/**
* Resolve stale comment threads via GraphQL.
* Returns the count and IDs of threads successfully resolved.
* Limited to MAX_STALE_RESOLUTIONS per run as a safeguard.
*/
export async function resolveStaleComments(
octokit: Octokit,
staleComments: ExistingComment[]
): Promise<ResolveResult> {
const resolvedIds = new Set<number>();
const commentsToResolve = staleComments.slice(0, MAX_STALE_RESOLUTIONS);
Iif (staleComments.length > MAX_STALE_RESOLUTIONS) {
console.log(
`Limiting stale comment resolution to ${MAX_STALE_RESOLUTIONS} of ${staleComments.length} comments`
);
}
for (const comment of commentsToResolve) {
Iif (!comment.threadId) {
continue;
}
try {
await octokit.graphql(RESOLVE_THREAD_MUTATION, {
threadId: comment.threadId,
});
resolvedIds.add(comment.id);
} catch (error) {
const errorMessage = String(error);
if (errorMessage.includes('Resource not accessible')) {
// Permission error affects all threads; log once and stop trying
console.warn(
`Failed to resolve thread: GitHub App may need 'contents:write' permission. ` +
`See: https://github.com/orgs/community/discussions/44650`
);
break;
}
console.warn(`Failed to resolve thread for comment ${comment.id}: ${error}`);
}
}
return { resolvedCount: resolvedIds.size, resolvedIds };
}
|