import type { Dirent } from "node:fs"; import { lstat, mkdir, readdir, realpath, rm } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import type { InvalidIssueFileDiagnostic, IssueFileListResult, IssueFileMetadata, IssueLookupResult, IssueMeConfig, IssueRecord, IssueWriteResult, } from "../types.ts"; import { IssueMeError, isNodeError } from "../errors.ts"; import { assertPathInside, parseIssueNumberFromFileName, resolveExistingIssueFilePath, resolveIssueDirectory, resolveIssueFilePath, toProjectRelativePath, } from "../utils/slug.ts"; import { assertNotAborted } from "../utils/abort.ts"; import { withCanonicalFileMutationQueue } from "../utils/mutation-queue.ts"; import { readTrustedTextFile, type TrustedTextFileReadOptions } from "../utils/safe-read.ts"; import { writeFileAtomicSafe } from "../utils/safe-write.ts"; interface IssueFileEntryCandidate { path: string; fileName: string; issueNumber: number; } type IssueFileEntryPreflightResult = | { kind: "skip" } | { kind: "invalid"; invalidFile: InvalidIssueFileDiagnostic } | { kind: "candidate"; candidate: IssueFileEntryCandidate }; type IssueFileEntryScanResult = | { kind: "skip" } | { kind: "invalid"; invalidFile: InvalidIssueFileDiagnostic } | { kind: "file"; file: IssueFileMetadata }; export async function writeIssueRecord(projectRoot: string, config: IssueMeConfig, record: IssueRecord, signal?: AbortSignal): Promise { assertNotAborted(signal); if (record.state !== "open") { const removedPaths = await removeIssueByNumber(projectRoot, config, record.number, record.repository, signal); return { action: "removed", removedPaths }; } const targetPath = resolveIssueFilePath(projectRoot, config.issueDirectory, record.number, record.title); await ensureIssueDirectorySafe(projectRoot, config, true); const writeResult = await withCanonicalFileMutationQueue(targetPath, async () => { assertNotAborted(signal); await mkdir(dirname(targetPath), { recursive: true }); const safeDirectory = await ensureIssueDirectorySafe(projectRoot, config, false); await ensureTargetFileSafe(targetPath, safeDirectory, projectRoot); const existingRecord = await readIssueFileIfExists(targetPath, safeDirectory, projectRoot); if (existingRecord && existingRecord.repository !== record.repository) { throw new IssueMeError( "issue_cache_repository_collision", `Issue cache file ${basename(targetPath)} already belongs to ${existingRecord.repository}; refusing to overwrite it with ${record.repository}.`, { fileName: basename(targetPath), existingRepository: existingRecord.repository, repository: record.repository, issueNumber: record.number }, ); } const recordToWrite = existingRecord && equivalentIssueRecordsIgnoringSyncedAt(existingRecord, record) ? { ...record, synced_at: existingRecord.synced_at } : record; const nextText = `${JSON.stringify(orderIssueRecord(recordToWrite), null, 2)}\n`; let currentText: string | undefined; try { await ensureTargetFileSafe(targetPath, safeDirectory, projectRoot); currentText = await readTrustedIssueFileText(targetPath, safeDirectory, projectRoot); } catch (error) { if (!(isNodeError(error) && error.code === "ENOENT")) throw error; } if (currentText === nextText) { assertNotAborted(signal); return { action: "unchanged" as const, path: targetPath, removedPaths: [] }; } assertNotAborted(signal); await ensureTargetFileSafe(targetPath, safeDirectory, projectRoot); assertNotAborted(signal); await writeFileAtomicSafe(targetPath, nextText, { validateBeforeCreate: async () => { assertNotAborted(signal); await assertIssueWriteTargetSafe(projectRoot, config, targetPath); }, validateBeforeRename: async () => { assertNotAborted(signal); await assertIssueWriteTargetSafe(projectRoot, config, targetPath); }, validateAfterRename: () => assertIssueWriteTargetSafe(projectRoot, config, targetPath), }); const action = currentText === undefined ? "created" as const : "updated" as const; return { action, path: targetPath, removedPaths: [] }; }); const staleFiles = (await listIssueFiles(projectRoot, config, { repository: record.repository })).filter( (file) => file.number === record.number && resolve(file.path) !== resolve(targetPath), ); assertNotAborted(signal); const removedPaths = await removeIssueFiles(projectRoot, config, staleFiles, signal); const action = writeResult.action === "created" && removedPaths.length > 0 ? "renamed" : writeResult.action; return { ...writeResult, action, removedPaths }; } export async function readIssueByNumber( projectRoot: string, config: IssueMeConfig, issueNumber: number, repository?: string, ): Promise { const files = await listIssueFiles(projectRoot, config, { repository }); const matches = files.filter((file) => file.number === issueNumber); const metadata = selectUniqueIssueNumberMatch(projectRoot, issueNumber, repository, matches); if (!metadata) return undefined; const directory = await ensureIssueDirectorySafe(projectRoot, config, false); return readIssueFile(metadata.path, directory, projectRoot); } export async function findIssueByLookup( projectRoot: string, config: IssueMeConfig, lookup: string, repository?: string, ): Promise { const cleanLookup = lookup.trim(); if (!cleanLookup) return undefined; const numeric = Number(cleanLookup.replace(/^#/, "")); if (Number.isSafeInteger(numeric) && numeric > 0) return findIssueByNumber(projectRoot, config, numeric, repository); if (cleanLookup.endsWith(".json") || cleanLookup.includes("/") || cleanLookup.includes("\\")) { try { const path = resolveExistingIssueFilePath(projectRoot, config.issueDirectory, cleanLookup); const directory = await ensureIssueDirectorySafe(projectRoot, config, false); await ensureTargetFileSafe(path, directory, projectRoot); const record = await readIssueFile(path, directory, projectRoot); if (repository && record.repository !== repository) return undefined; return { record, path, metadata: issueMetadataFromRecord(path, record) }; } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return undefined; throw error; } } const needle = cleanLookup.toLowerCase(); const files = await listIssueFiles(projectRoot, config, { repository }); const matches = files.filter((file) => file.fileName.toLowerCase().includes(needle) || file.title.toLowerCase().includes(needle)); if (matches.length === 0) return undefined; if (matches.length > 1) { throw new IssueMeError("issue_lookup_ambiguous", `Issue lookup "${cleanLookup}" matches multiple local IssueMe files.`); } const metadata = matches[0]; const directory = await ensureIssueDirectorySafe(projectRoot, config, false); return { record: await readIssueFile(metadata.path, directory, projectRoot), path: metadata.path, metadata }; } export async function readIssueByLookup(projectRoot: string, config: IssueMeConfig, lookup: string): Promise { return (await findIssueByLookup(projectRoot, config, lookup))?.record; } export async function findIssueByNumber( projectRoot: string, config: IssueMeConfig, issueNumber: number, repository?: string, ): Promise { const files = await listIssueFiles(projectRoot, config, { repository }); const matches = files.filter((file) => file.number === issueNumber); const metadata = selectUniqueIssueNumberMatch(projectRoot, issueNumber, repository, matches); if (!metadata) return undefined; const directory = await ensureIssueDirectorySafe(projectRoot, config, false); return { record: await readIssueFile(metadata.path, directory, projectRoot), path: metadata.path, metadata }; } function selectUniqueIssueNumberMatch( projectRoot: string, issueNumber: number, repository: string | undefined, matches: IssueFileMetadata[], ): IssueFileMetadata | undefined { if (matches.length === 0) return undefined; if (matches.length === 1) return matches[0]; throw issueNumberLookupAmbiguousError(projectRoot, issueNumber, repository, matches); } function issueNumberLookupAmbiguousError(projectRoot: string, issueNumber: number, repository: string | undefined, matches: IssueFileMetadata[]): IssueMeError { const sortedMatches = [...matches].sort((a, b) => a.repository.localeCompare(b.repository) || a.fileName.localeCompare(b.fileName)); const safeMatches = sortedMatches.map((file) => ({ repository: file.repository, number: file.number, title: file.title, path: toProjectRelativePath(projectRoot, file.path), updated_at: file.updated_at, })); const paths = safeMatches.map((file) => file.path); const repositories = new Set(safeMatches.map((file) => file.repository)); const duplicateRepository = repositories.size === 1 ? safeMatches[0]?.repository : undefined; const duplicateScope = repository ?? duplicateRepository; const pathText = paths.length ? ` Matching files: ${paths.join(", ")}.` : ""; if (duplicateScope && repositories.size === 1) { return new IssueMeError( "issue_lookup_ambiguous", `Issue #${issueNumber} has multiple local IssueMe cache files for ${duplicateScope}. Run issueme_sync_issues or remove stale duplicate cache files before using this lookup.${pathText}`, { issueNumber, repository: duplicateScope, paths, matches: safeMatches }, { recoveryHint: "Run issueme_sync_issues for the repository, or remove stale duplicate local issue JSON files before reading by issue number again." }, ); } return new IssueMeError( "issue_lookup_ambiguous", `Issue #${issueNumber} matches multiple repositories in the local IssueMe cache. Use a repository-scoped lookup, filename, or run issueme_sync_issues before relying on local cache state.${pathText}`, { issueNumber, ...(repository ? { repository } : {}), paths, matches: safeMatches }, ); } export async function listIssueFiles( projectRoot: string, config: IssueMeConfig, options: { repository?: string } = {}, ): Promise { return (await listIssueFileEntries(projectRoot, config, options)).files; } export async function listIssueFileEntries( projectRoot: string, config: IssueMeConfig, options: { repository?: string } = {}, ): Promise { const directory = await ensureIssueDirectorySafe(projectRoot, config, false); const entries = await readIssueDirectoryEntries(directory); const files: IssueFileMetadata[] = []; const invalidFiles: InvalidIssueFileDiagnostic[] = []; for (const entry of entries) { const scanResult = await scanIssueFileEntry(projectRoot, directory, entry, options); if (scanResult.kind === "file") { files.push(scanResult.file); continue; } if (scanResult.kind === "invalid") invalidFiles.push(scanResult.invalidFile); } files.sort((a, b) => a.number - b.number || a.repository.localeCompare(b.repository) || a.fileName.localeCompare(b.fileName)); invalidFiles.sort((a, b) => a.fileName.localeCompare(b.fileName)); return { files, invalidFiles }; } async function readIssueDirectoryEntries(directory: string): Promise { try { return await readdir(directory, { withFileTypes: true }); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return []; throw error; } } async function scanIssueFileEntry( projectRoot: string, directory: string, entry: Dirent, options: { repository?: string }, ): Promise { const preflight = preflightIssueFileEntry(directory, entry); if (preflight.kind !== "candidate") return preflight; const { candidate } = preflight; try { await ensureTargetFileSafe(candidate.path, directory, projectRoot); const record = await readIssueFile(candidate.path, directory, projectRoot); if (record.number !== candidate.issueNumber) { return { kind: "invalid", invalidFile: { path: candidate.path, fileName: candidate.fileName, reason: "issue_file_number_mismatch" } }; } if (options.repository && record.repository !== options.repository) return { kind: "skip" }; return { kind: "file", file: issueMetadataFromRecord(candidate.path, record) }; } catch (error) { return { kind: "invalid", invalidFile: { path: candidate.path, fileName: candidate.fileName, reason: issueFileDiagnosticReason(error) } }; } } function preflightIssueFileEntry(directory: string, entry: Dirent): IssueFileEntryPreflightResult { if (!entry.name.endsWith(".json")) return { kind: "skip" }; const path = resolve(directory, entry.name); if (!entry.isFile() && !entry.isSymbolicLink()) return { kind: "invalid", invalidFile: { path, fileName: entry.name, reason: "issue_file_not_regular" } }; const issueNumber = parseIssueNumberFromFileName(entry.name); if (!issueNumber) return { kind: "invalid", invalidFile: { path, fileName: entry.name, reason: "issue_file_name_invalid" } }; return { kind: "candidate", candidate: { path, fileName: entry.name, issueNumber } }; } export async function readIssueFile(path: string, safeDirectory?: string, projectRoot?: string): Promise { await ensureTargetFileSafe(path, safeDirectory, projectRoot); const text = await readTrustedIssueFileText(path, safeDirectory, projectRoot); let parsed: unknown; try { parsed = JSON.parse(text) as unknown; } catch { throw new IssueMeError("issue_file_parse_failed", `Issue file ${basename(path)} is not valid JSON.`, { fileName: basename(path), reason: "issue_file_parse_failed", }); } const validationFailure = validateIssueRecord(parsed); if (validationFailure) { throw new IssueMeError( "issue_file_invalid", `Issue file ${basename(path)} is not a valid IssueMe issue JSON file (${validationFailure.field}).`, { fileName: basename(path), ...validationFailure }, ); } return parsed as IssueRecord; } export function issueFileDiagnosticReason(error: unknown): string { if (error instanceof IssueMeError) { const reason = error.safeDetails?.reason; return typeof reason === "string" ? reason : error.code; } return "issue_file_read_failed"; } export async function removeIssueByNumber( projectRoot: string, config: IssueMeConfig, issueNumber: number, repository?: string, signal?: AbortSignal, ): Promise { assertNotAborted(signal); const files = (await listIssueFiles(projectRoot, config, { repository })).filter((file) => file.number === issueNumber); if (!repository && files.length > 1) { throw new IssueMeError("issue_lookup_ambiguous", `Issue #${issueNumber} matches multiple repositories in the local IssueMe cache.`); } return removeIssueFiles(projectRoot, config, files, signal); } export async function removeClosedIssueFiles( projectRoot: string, config: IssueMeConfig, openIssueNumbers: ReadonlySet, repository?: string, signal?: AbortSignal, ): Promise { const files = await listIssueFiles(projectRoot, config, { repository }); assertNotAborted(signal); return removeIssueFiles(projectRoot, config, files.filter((file) => file.state === "closed" || !openIssueNumbers.has(file.number)), signal); } export function relativeIssuePath(projectRoot: string, absolutePath: string | undefined): string | undefined { return absolutePath ? toProjectRelativePath(projectRoot, absolutePath) : undefined; } async function readTrustedIssueFileText(path: string, safeDirectory?: string, projectRoot?: string): Promise { const options: TrustedTextFileReadOptions = { unsafeCode: "unsafe_issue_file", unsafeMessage: "IssueMe refuses to read or mutate symlinked issue files.", notFileMessage: "Issue cache path exists but is not a regular file.", raceSwapMessage: "Issue cache file changed while it was being opened for reading.", }; assignTrustedTextFilePathOption(options, "projectRoot", projectRoot); assignTrustedTextFilePathOption(options, "safeDirectory", safeDirectory); return readTrustedTextFile(path, options); } function assignTrustedTextFilePathOption(options: TrustedTextFileReadOptions, field: "projectRoot" | "safeDirectory", value: string | undefined): void { if (typeof value === "string") options[field] = value; } async function removeIssueFiles(projectRoot: string, config: IssueMeConfig, files: IssueFileMetadata[], signal?: AbortSignal): Promise { const removed: string[] = []; for (const file of files) { assertNotAborted(signal); await withCanonicalFileMutationQueue(file.path, async () => { assertNotAborted(signal); const directory = await ensureIssueDirectorySafe(projectRoot, config, false); await ensureTargetFileSafe(file.path, directory, projectRoot); assertNotAborted(signal); await rm(file.path, { force: true }); }); removed.push(file.path); } return removed; } async function assertIssueWriteTargetSafe(projectRoot: string, config: IssueMeConfig, targetPath: string): Promise { const directory = await ensureIssueDirectorySafe(projectRoot, config, false); await ensureTargetFileSafe(targetPath, directory, projectRoot); } async function ensureIssueDirectorySafe(projectRoot: string, config: IssueMeConfig, forWrite: boolean): Promise { const directory = resolveIssueDirectory(projectRoot, config.issueDirectory); const rootRealPath = await realpath(projectRoot); try { const directoryStat = await lstat(directory); if (directoryStat.isSymbolicLink()) { throw new IssueMeError("unsafe_issue_directory", "Issue directory cannot be a symlink."); } if (!directoryStat.isDirectory()) { throw new IssueMeError("unsafe_issue_directory", "Issue directory path exists but is not a directory."); } assertPathInside(rootRealPath, await realpath(directory), "Issue directory must resolve inside the current project."); return directory; } catch (error) { if (!(isNodeError(error) && error.code === "ENOENT")) throw error; if (!forWrite) return directory; const parentRealPath = await nearestExistingParentRealPath(projectRoot, directory); assertPathInside(rootRealPath, parentRealPath, "Issue directory parent must resolve inside the current project."); return directory; } } async function nearestExistingParentRealPath(projectRoot: string, target: string): Promise { let current = dirname(target); while (true) { try { const stat = await lstat(current); if (stat.isSymbolicLink()) throw new IssueMeError("unsafe_issue_directory", "Issue directory parent cannot be a symlink."); return await realpath(current); } catch (error) { if (!(isNodeError(error) && error.code === "ENOENT")) throw error; } const parent = dirname(current); if (parent === current || resolve(current) === resolve(projectRoot)) return await realpath(projectRoot); current = parent; } } async function ensureTargetFileSafe(path: string, safeDirectory?: string, projectRoot?: string): Promise { const rootRealPath = await realPathIfDefined(projectRoot); const safeDirectoryRealPath = await realPathIfDefined(safeDirectory); assertSafeDirectoryInsideRoot(rootRealPath, safeDirectoryRealPath); try { await ensureExistingTargetFileSafe(path, rootRealPath, safeDirectoryRealPath); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") { await ensureMissingTargetFileSafe(path, rootRealPath, safeDirectoryRealPath); return; } throw error; } } async function realPathIfDefined(path: string | undefined): Promise { if (path === undefined) return undefined; return realpath(path); } function assertSafeDirectoryInsideRoot(rootRealPath: string | undefined, safeDirectoryRealPath: string | undefined): void { if (rootRealPath === undefined || safeDirectoryRealPath === undefined) return; assertPathInside(rootRealPath, safeDirectoryRealPath, "Issue directory must resolve inside the current project."); } async function ensureExistingTargetFileSafe(path: string, rootRealPath: string | undefined, safeDirectoryRealPath: string | undefined): Promise { const stat = await lstat(path); if (stat.isSymbolicLink()) throw new IssueMeError("unsafe_issue_file", "IssueMe refuses to read or mutate symlinked issue files."); if (!stat.isFile()) throw new IssueMeError("unsafe_issue_file", "Issue cache path exists but is not a regular file."); const fileRealPath = await realpath(path); if (rootRealPath !== undefined) assertPathInside(rootRealPath, fileRealPath, "Issue cache file must resolve inside the current project."); if (safeDirectoryRealPath !== undefined) { assertPathInside( safeDirectoryRealPath, fileRealPath, "Issue cache file must resolve inside the configured issue directory.", ); } } async function ensureMissingTargetFileSafe(path: string, rootRealPath: string | undefined, safeDirectoryRealPath: string | undefined): Promise { if (safeDirectoryRealPath === undefined) return; const parentRealPath = await realpath(dirname(path)); if (rootRealPath !== undefined) assertPathInside(rootRealPath, parentRealPath, "Issue cache file parent must resolve inside the current project."); assertPathInside(safeDirectoryRealPath, parentRealPath, "Issue cache file parent must resolve inside the configured issue directory."); } async function readIssueFileIfExists(path: string, safeDirectory?: string, projectRoot?: string): Promise { try { return await readIssueFile(path, safeDirectory, projectRoot); } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return undefined; throw error; } } function issueMetadataFromRecord(path: string, record: IssueRecord): IssueFileMetadata { return { path, fileName: basename(path), repository: record.repository, number: record.number, title: record.title, state: record.state, ...(record.creator ? { creator: record.creator } : {}), updated_at: record.updated_at, }; } function equivalentIssueRecordsIgnoringSyncedAt(left: IssueRecord, right: IssueRecord): boolean { return JSON.stringify({ ...orderIssueRecord(left), synced_at: "" }) === JSON.stringify({ ...orderIssueRecord(right), synced_at: "" }); } interface IssueValidationFailure { reason: string; field: string; } function validateIssueRecord(value: unknown): IssueValidationFailure | undefined { if (!isObject(value)) return validationFailure("issue_file_schema_invalid", "root"); const record = value as Partial; return validateIssueRecordCore(record) ?? validateIssueRecordRelationships(record) ?? validateIssueRecordComments(record) ?? validateIssueRecordUrlsAndTimestamps(record); } function validateIssueRecordCore(record: Partial): IssueValidationFailure | undefined { if (record.schemaVersion !== 1) return validationFailure("issue_file_schema_version_invalid", "schemaVersion"); if (!isRepositoryName(record.repository)) return validationFailure("issue_file_repository_invalid", "repository"); if (typeof record.number !== "number" || !Number.isSafeInteger(record.number) || record.number <= 0) return validationFailure("issue_file_number_invalid", "number"); if (!isNonEmptySafeString(record.title)) return validationFailure("issue_file_title_invalid", "title"); if (record.state !== "open" && record.state !== "closed") return validationFailure("issue_file_state_invalid", "state"); if (record.creator !== undefined && !isGitHubLogin(record.creator)) return validationFailure("issue_file_creator_invalid", "creator"); if (typeof record.body !== "string") return validationFailure("issue_file_body_invalid", "body"); if (!isLabelList(record.labels)) return validationFailure("issue_file_labels_invalid", "labels"); if (!isAssigneeList(record.assignees)) return validationFailure("issue_file_assignees_invalid", "assignees"); if (record.milestone !== null && !isNonEmptySafeString(record.milestone)) return validationFailure("issue_file_milestone_invalid", "milestone"); return undefined; } function validateIssueRecordRelationships(record: Partial): IssueValidationFailure | undefined { if (record.parent_issue !== undefined && record.parent_issue !== null && !isIssueRelationshipSummary(record.parent_issue)) return validationFailure("issue_file_relationship_invalid", "parent_issue"); if (record.sub_issues !== undefined && !isIssueRelationshipSummaryList(record.sub_issues)) return validationFailure("issue_file_relationship_invalid", "sub_issues"); if (record.sub_issues_count !== undefined && !isNonNegativeSafeInteger(record.sub_issues_count)) return validationFailure("issue_file_relationship_invalid", "sub_issues_count"); if (typeof record.sub_issues_count === "number" && record.sub_issues !== undefined && record.sub_issues_count < record.sub_issues.length) return validationFailure("issue_file_relationship_invalid", "sub_issues_count"); return undefined; } function validateIssueRecordComments(record: Partial): IssueValidationFailure | undefined { if (!Array.isArray(record.comments)) return validationFailure("issue_file_comments_invalid", "comments"); const metadataFailure = validateIssueRecordCommentMetadata(record); if (metadataFailure) return metadataFailure; for (let index = 0; index < record.comments.length; index += 1) { const commentFailure = validateIssueCommentRecord(record.comments[index], record.repository ?? "", record.number ?? 0, index); if (commentFailure) return commentFailure; } return undefined; } function validateIssueRecordCommentMetadata(record: Partial): IssueValidationFailure | undefined { if (record.comments_truncated !== undefined && typeof record.comments_truncated !== "boolean") return validationFailure("issue_file_comments_metadata_invalid", "comments_truncated"); if (record.comments_count !== undefined && !isNonNegativeSafeInteger(record.comments_count)) return validationFailure("issue_file_comments_metadata_invalid", "comments_count"); if (record.comments_fetch_limit !== undefined && !isNonNegativeSafeInteger(record.comments_fetch_limit)) return validationFailure("issue_file_comments_metadata_invalid", "comments_fetch_limit"); if (typeof record.comments_count === "number" && record.comments && record.comments_count < record.comments.length) return validationFailure("issue_file_comments_metadata_invalid", "comments_count"); return undefined; } function validateIssueRecordUrlsAndTimestamps(record: Partial): IssueValidationFailure | undefined { if (!isGitHubIssueUrl(record.html_url, record.repository ?? "", record.number ?? 0)) return validationFailure("issue_file_url_invalid", "html_url"); if (!isIsoTimestamp(record.created_at)) return validationFailure("issue_file_timestamp_invalid", "created_at"); if (!isIsoTimestamp(record.updated_at)) return validationFailure("issue_file_timestamp_invalid", "updated_at"); if (record.closed_at !== null && !isIsoTimestamp(record.closed_at)) return validationFailure("issue_file_timestamp_invalid", "closed_at"); if (!isIsoTimestamp(record.synced_at)) return validationFailure("issue_file_timestamp_invalid", "synced_at"); return undefined; } function validateIssueCommentRecord( value: unknown, repository: string, issueNumber: number, index: number, ): IssueValidationFailure | undefined { const fieldPrefix = `comments[${index}]`; if (!isObject(value)) return validationFailure("issue_file_comment_invalid", fieldPrefix); const comment = value as Record; if (typeof comment.id !== "number" || !Number.isSafeInteger(comment.id) || comment.id <= 0) return validationFailure("issue_file_comment_id_invalid", `${fieldPrefix}.id`); if (!isGitHubLogin(comment.author)) return validationFailure("issue_file_comment_author_invalid", `${fieldPrefix}.author`); if (typeof comment.body !== "string") return validationFailure("issue_file_comment_body_invalid", `${fieldPrefix}.body`); if (!isIsoTimestamp(comment.created_at)) return validationFailure("issue_file_timestamp_invalid", `${fieldPrefix}.created_at`); if (!isIsoTimestamp(comment.updated_at)) return validationFailure("issue_file_timestamp_invalid", `${fieldPrefix}.updated_at`); if (!isGitHubCommentUrl(comment.html_url, repository, issueNumber, comment.id)) return validationFailure("issue_file_comment_url_invalid", `${fieldPrefix}.html_url`); return undefined; } function validationFailure(reason: string, field: string): IssueValidationFailure { return { reason, field }; } function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isRepositoryName(value: unknown): value is string { if (typeof value !== "string") return false; const [owner, repo, extra] = value.split("/"); return extra === undefined && isGitHubLogin(owner) && typeof repo === "string" && /^[A-Za-z0-9._-]+$/.test(repo); } function isGitHubLogin(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(value); } function isNonNegativeSafeInteger(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } function isNonEmptySafeString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0 && !value.includes("\0"); } function isLabelList(value: unknown): value is string[] { return Array.isArray(value) && value.every((label) => isNonEmptySafeString(label)); } function isAssigneeList(value: unknown): value is string[] { return Array.isArray(value) && value.every(isGitHubLogin); } function isIssueRelationshipSummary(value: unknown): boolean { if (!isObject(value)) return false; const issue = value as Record; if (typeof issue.number !== "number" || !Number.isSafeInteger(issue.number) || issue.number <= 0) return false; if (!isNonEmptySafeString(issue.title)) return false; if (issue.state !== undefined && issue.state !== "open" && issue.state !== "closed") return false; if (issue.creator !== undefined && !isGitHubLogin(issue.creator)) return false; if (typeof issue.html_url !== "string") return false; return parseHttpsGitHubUrl(issue.html_url) !== undefined; } function isIssueRelationshipSummaryList(value: unknown): boolean { return Array.isArray(value) && value.every(isIssueRelationshipSummary); } function isIsoTimestamp(value: unknown): value is string { if (typeof value !== "string") return false; if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value)) return false; return !Number.isNaN(Date.parse(value)); } function isGitHubIssueUrl(value: unknown, repository: string, issueNumber: number): value is string { const url = parseHttpsGitHubUrl(value); if (url instanceof URL) { const [owner, repo, type, number, ...rest] = url.pathname.split("/").filter(Boolean); return rest.length === 0 && ownerRepoMatches(repository, owner, repo) && type === "issues" && number === String(issueNumber); } return false; } function isGitHubCommentUrl(value: unknown, repository: string, issueNumber: number, commentId: number): value is string { const url = parseHttpsGitHubUrl(value); if (url instanceof URL) { const [owner, repo, type, number, ...rest] = url.pathname.split("/").filter(Boolean); return rest.length === 0 && ownerRepoMatches(repository, owner, repo) && type === "issues" && number === String(issueNumber) && url.hash === `#issuecomment-${commentId}`; } return false; } function parseHttpsGitHubUrl(value: unknown): URL | undefined { if (typeof value === "string") return parseHttpsGitHubUrlString(value); return undefined; } function parseHttpsGitHubUrlString(value: string): URL | undefined { try { const url = new URL(value); if (isGitHubWebUrl(url)) return url; } catch { return undefined; } return undefined; } function isGitHubWebUrl(url: URL): boolean { return url.protocol === "https:" && url.hostname === "github.com"; } function ownerRepoMatches(repository: string, owner: string | undefined, repo: string | undefined): boolean { const [expectedOwner, expectedRepo] = repository.split("/"); return owner?.toLowerCase() === expectedOwner.toLowerCase() && repo?.toLowerCase() === expectedRepo.toLowerCase(); } function orderIssueRecord(record: IssueRecord): IssueRecord { const ordered: Partial = { schemaVersion: 1, repository: record.repository, number: record.number, title: record.title, state: record.state, }; if (record.creator !== undefined) ordered.creator = record.creator; ordered.body = record.body; ordered.labels = record.labels; ordered.assignees = record.assignees; ordered.milestone = record.milestone; if (record.parent_issue !== undefined) ordered.parent_issue = record.parent_issue; if (record.sub_issues !== undefined) ordered.sub_issues = record.sub_issues; if (record.sub_issues_count !== undefined) ordered.sub_issues_count = record.sub_issues_count; ordered.comments = record.comments; if (record.comments_truncated !== undefined) ordered.comments_truncated = record.comments_truncated; if (record.comments_count !== undefined) ordered.comments_count = record.comments_count; if (record.comments_fetch_limit !== undefined) ordered.comments_fetch_limit = record.comments_fetch_limit; ordered.html_url = record.html_url; ordered.created_at = record.created_at; ordered.updated_at = record.updated_at; ordered.closed_at = record.closed_at; ordered.synced_at = record.synced_at; return ordered as IssueRecord; }