Files
Sebastian Poxhofer e0cec3dc95 refactor(lint): add types-location rule and enable it for lib/modules (#45497)
feat(lint): add `types-location` rule and enable it for `lib/modules`

Reviewers consistently ask for exported types to be moved into a
colocated `types.ts`. Add a `renovate/types-location` oxlint rule that
reports `export interface` / `export type` declarations outside such a
file, and turn it on across `lib/modules`.

Re-exports (`export type { X }`, `export type *`), file-local types, and
values (enums, classes, functions) are left alone. `schema.ts` files and
`schema/` directories are excluded via config, matching how
`zod-schema-location` is configured — inferred types belong next to the
schema they are derived from.

Relocating the 87 existing violations means:

- new `types.ts` files for the modules that had none
- `terragrunt/common.ts` folded into its `types.ts`, as it held nothing
  else
- `RangeBound` in `versioning/gradle/compare.ts` un-exported, since only
  a file-local interface referenced it
- `bazel-module/parser/fragments.ts` split from its inferred types, which
  now live in `parser/types.ts` and infer through a namespace import so
  each schema keeps its merged name
2026-08-27 19:52:09 +00:00

196 lines
5.4 KiB
TypeScript

import { logger } from '../../../logger/index.ts';
import { BitbucketHttp } from '../../../util/http/bitbucket.ts';
import type {
EnsureCommentConfig,
EnsureCommentRemovalConfig,
} from '../types.ts';
import type { Account, CommentsConfig, PagedResult } from './types.ts';
export const REOPEN_PR_COMMENT_KEYWORD = 'reopen!';
const bitbucketHttp = new BitbucketHttp();
interface Comment {
content: { raw: string };
id: number;
user: Account;
}
interface EnsureBitbucketCommentConfig extends EnsureCommentConfig {
config: CommentsConfig;
}
async function getComments(
config: CommentsConfig,
prNo: number,
): Promise<Comment[]> {
const comments = (
await bitbucketHttp.getJsonUnchecked<PagedResult<Comment>>(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/comments`,
{
paginate: true,
},
)
).body.values;
logger.debug(`Found ${comments.length} comments`);
return comments;
}
async function addComment(
config: CommentsConfig,
prNo: number,
raw: string,
): Promise<void> {
await bitbucketHttp.postJson(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/comments`,
{
body: { content: { raw } },
},
);
}
async function editComment(
config: CommentsConfig,
prNo: number,
commentId: number,
raw: string,
): Promise<void> {
await bitbucketHttp.putJson(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/comments/${commentId}`,
{
body: { content: { raw } },
},
);
}
async function deleteComment(
config: CommentsConfig,
prNo: number,
commentId: number,
): Promise<void> {
await bitbucketHttp.deleteJson(
`/2.0/repositories/${config.repository}/pullrequests/${prNo}/comments/${commentId}`,
);
}
export async function ensureComment({
config,
number: prNo,
topic,
content,
}: EnsureBitbucketCommentConfig): Promise<boolean> {
try {
const comments = await getComments(config, prNo);
let body: string;
let commentId: number | undefined;
let commentNeedsUpdating: boolean | undefined;
if (topic) {
logger.debug(`Ensuring comment "${topic}" in #${prNo}`);
body = `### ${topic}\n\n${content}`;
comments.forEach((comment) => {
// v8 ignore else -- TODO: add test #40625
if (comment.content.raw.startsWith(`### ${topic}\n\n`)) {
commentId = comment.id;
commentNeedsUpdating = comment.content.raw !== body;
}
});
} else {
logger.debug(`Ensuring content-only comment in #${prNo}`);
body = `${content}`;
comments.forEach((comment) => {
// v8 ignore else -- TODO: add test #40625
if (comment.content.raw === body) {
commentId = comment.id;
commentNeedsUpdating = false;
}
});
}
// sanitize any language that isn't supported by Bitbucket Cloud
body = sanitizeCommentBody(body);
if (!commentId) {
await addComment(config, prNo, body);
logger.info(
{ repository: config.repository, prNo, topic },
'Comment added',
);
} else if (commentNeedsUpdating) {
await editComment(config, prNo, commentId, body);
logger.debug({ repository: config.repository, prNo }, 'Comment updated');
} else {
logger.debug('Comment is already up-to-date');
}
return true;
} catch (err) /* v8 ignore next -- defensive: comment API failures are logged and swallowed, not simulated in specs */ {
logger.warn({ err }, 'Error ensuring comment');
return false;
}
}
export async function reopenComments(
config: CommentsConfig,
prNo: number,
): Promise<Comment[]> {
const comments = await getComments(config, prNo);
const reopenComments = comments.filter((comment) =>
comment.content.raw.startsWith(REOPEN_PR_COMMENT_KEYWORD),
);
return reopenComments;
}
function byTopic(comment: Comment, topic: string): boolean {
return comment.content.raw.startsWith(`### ${topic}\n\n`);
}
function byContent(comment: Comment, content: string): boolean {
return comment.content.raw.trim() === content;
}
export async function ensureCommentRemoval(
config: CommentsConfig,
deleteConfig: EnsureCommentRemovalConfig,
): Promise<void> {
try {
const { number: prNo } = deleteConfig;
const key =
deleteConfig.type === 'by-topic'
? deleteConfig.topic
: deleteConfig.content;
logger.debug(`Ensuring comment "${key}" in #${prNo} is removed`);
const comments = await getComments(config, prNo);
let commentId: number | undefined = undefined;
// v8 ignore else -- TODO: add test #40625
if (deleteConfig.type === 'by-topic') {
const topic = deleteConfig.topic;
commentId = comments.find((comment) => byTopic(comment, topic))?.id;
} else if (deleteConfig.type === 'by-content') {
const content = deleteConfig.content;
commentId = comments.find((comment) => byContent(comment, content))?.id;
}
if (commentId) {
await deleteComment(config, prNo, commentId);
}
} catch (err) /* v8 ignore next -- defensive: comment API failures are logged and swallowed, not simulated in specs */ {
logger.warn({ err }, 'Error ensuring comment removal');
}
}
function sanitizeCommentBody(body: string): string {
return body
.replace(
'checking the rebase/retry box above',
'renaming this PR to start with "rebase!"',
)
.replace(
'rename this PR to get a fresh replacement',
'add a comment starting with "reopen!" to get a fresh replacement',
);
}