All files / src/output stale.ts

86.27% Statements 44/51
85.18% Branches 23/27
100% Functions 9/9
86% Lines 43/50

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 };
}