@code-pushup/ci 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,7 +1,9 @@
1
- // packages/ci/src/lib/run.ts
2
- import fs from "node:fs/promises";
3
- import path2 from "node:path";
4
- import { simpleGit as simpleGit4 } from "simple-git";
1
+ // packages/ci/src/lib/monorepo/list-projects.ts
2
+ import { glob as glob2 } from "glob";
3
+ import { join as join7 } from "node:path";
4
+
5
+ // packages/ci/src/lib/monorepo/handlers/npm.ts
6
+ import { join as join2 } from "node:path";
5
7
 
6
8
  // packages/models/src/lib/implementation/schemas.ts
7
9
  import { MATERIAL_ICONS } from "vscode-material-icons";
@@ -851,445 +853,74 @@ import { MarkdownDocument as MarkdownDocument4, md as md5 } from "build-md";
851
853
  // packages/utils/src/lib/reports/log-stdout-summary.ts
852
854
  import { bold as bold4, cyan, cyanBright, green as green2, red } from "ansis";
853
855
 
854
- // packages/ci/src/lib/cli/persist.ts
855
- import path from "node:path";
856
- function persistCliOptions({
857
- directory,
858
- project
859
- }) {
860
- return [
861
- `--persist.outputDir=${path.join(directory, DEFAULT_PERSIST_OUTPUT_DIR)}`,
862
- `--persist.filename=${createFilename(project)}`,
863
- ...DEFAULT_PERSIST_FORMAT.map((format) => `--persist.format=${format}`)
864
- ];
865
- }
866
- function persistedCliFiles({
867
- directory,
868
- isDiff,
869
- project,
870
- formats
871
- }) {
872
- const rootDir = path.join(directory, DEFAULT_PERSIST_OUTPUT_DIR);
873
- const filename = isDiff ? `${createFilename(project)}-diff` : createFilename(project);
874
- const filePaths = (formats ?? DEFAULT_PERSIST_FORMAT).reduce(
875
- (acc, format) => ({
876
- ...acc,
877
- [`${format}FilePath`]: path.join(rootDir, `${filename}.${format}`)
878
- }),
879
- // eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, @typescript-eslint/consistent-type-assertions
880
- {}
856
+ // packages/ci/src/lib/monorepo/packages.ts
857
+ import { glob } from "glob";
858
+ import { basename, dirname, join } from "node:path";
859
+ async function listPackages(cwd, patterns = ["**"]) {
860
+ const files = await glob(
861
+ patterns.map((pattern) => pattern.replace(/\/?$/, "/package.json")),
862
+ { cwd }
881
863
  );
882
- const files = Object.values(filePaths);
864
+ return Promise.all(
865
+ files.toSorted().map(async (file) => {
866
+ const packageJson = await readJsonFile(join(cwd, file));
867
+ const directory = join(cwd, dirname(file));
868
+ const name = packageJson.name || basename(directory);
869
+ return { name, directory, packageJson };
870
+ })
871
+ );
872
+ }
873
+ async function listWorkspaces(cwd) {
874
+ const rootPackageJson = await readRootPackageJson(cwd);
875
+ const patterns = Array.isArray(rootPackageJson.workspaces) ? rootPackageJson.workspaces : rootPackageJson.workspaces?.packages;
883
876
  return {
884
- ...filePaths,
885
- artifactData: {
886
- rootDir,
887
- files
888
- }
877
+ workspaces: await listPackages(cwd, patterns),
878
+ rootPackageJson
889
879
  };
890
880
  }
891
- function createFilename(project) {
892
- if (!project) {
893
- return DEFAULT_PERSIST_FILENAME;
894
- }
895
- const prefix = projectToFilename(project);
896
- return `${prefix}-${DEFAULT_PERSIST_FILENAME}`;
897
- }
898
-
899
- // packages/ci/src/lib/cli/commands/collect.ts
900
- async function runCollect({
901
- bin,
902
- config,
903
- directory,
904
- silent,
905
- project
906
- }) {
907
- const { stdout } = await executeProcess({
908
- command: bin,
909
- args: [
910
- ...config ? [`--config=${config}`] : [],
911
- ...persistCliOptions({ directory, project })
912
- ],
913
- cwd: directory
914
- });
915
- if (!silent) {
916
- console.info(stdout);
881
+ async function hasWorkspacesEnabled(cwd) {
882
+ const packageJson = await readRootPackageJson(cwd);
883
+ if (!packageJson.private) {
884
+ return false;
917
885
  }
918
- return persistedCliFiles({ directory, project });
919
- }
920
-
921
- // packages/ci/src/lib/cli/commands/compare.ts
922
- async function runCompare({ before, after, label }, { bin, config, directory, silent, project }) {
923
- const { stdout } = await executeProcess({
924
- command: bin,
925
- args: [
926
- "compare",
927
- `--before=${before}`,
928
- `--after=${after}`,
929
- ...label ? [`--label=${label}`] : [],
930
- ...config ? [`--config=${config}`] : [],
931
- ...persistCliOptions({ directory, project })
932
- ],
933
- cwd: directory
934
- });
935
- if (!silent) {
936
- console.info(stdout);
886
+ if (Array.isArray(packageJson.workspaces)) {
887
+ return packageJson.workspaces.length > 0;
937
888
  }
938
- return persistedCliFiles({ directory, isDiff: true, project });
939
- }
940
-
941
- // packages/ci/src/lib/cli/commands/merge-diffs.ts
942
- async function runMergeDiffs(files, { bin, config, directory, silent }) {
943
- const { stdout } = await executeProcess({
944
- command: bin,
945
- args: [
946
- "merge-diffs",
947
- ...files.map((file) => `--files=${file}`),
948
- ...config ? [`--config=${config}`] : [],
949
- ...persistCliOptions({ directory })
950
- ],
951
- cwd: directory
952
- });
953
- if (!silent) {
954
- console.info(stdout);
889
+ if (typeof packageJson.workspaces === "object") {
890
+ return Boolean(packageJson.workspaces.packages?.length);
955
891
  }
956
- return persistedCliFiles({ directory, isDiff: true, formats: ["md"] });
892
+ return false;
957
893
  }
958
-
959
- // packages/ci/src/lib/cli/commands/print-config.ts
960
- async function runPrintConfig({
961
- bin,
962
- config,
963
- directory,
964
- silent
965
- }) {
966
- const { stdout } = await executeProcess({
967
- command: bin,
968
- args: [...config ? [`--config=${config}`] : [], "print-config"],
969
- cwd: directory
970
- });
971
- if (!silent) {
972
- console.info(stdout);
973
- }
894
+ async function readRootPackageJson(cwd) {
895
+ return await readJsonFile(join(cwd, "package.json"));
974
896
  }
975
-
976
- // packages/ci/src/lib/cli/context.ts
977
- function createCommandContext(settings, project) {
978
- return {
979
- project: project?.name,
980
- bin: project?.bin ?? settings.bin,
981
- directory: project?.directory ?? settings.directory,
982
- config: settings.config,
983
- silent: settings.silent
984
- };
897
+ function hasDependency(packageJson, name) {
898
+ const { dependencies = {}, devDependencies = {} } = packageJson;
899
+ return name in devDependencies || name in dependencies;
985
900
  }
986
-
987
- // packages/ci/src/lib/comment.ts
988
- import { readFile as readFile2 } from "node:fs/promises";
989
- async function commentOnPR(mdPath, api, logger) {
990
- const markdown = await readFile2(mdPath, "utf8");
991
- const identifier = `<!-- generated by @code-pushup/ci -->`;
992
- const body = truncateBody(
993
- `${markdown}
994
-
995
- ${identifier}
996
- `,
997
- api.maxCommentChars,
998
- logger
999
- );
1000
- const comments = await api.listComments();
1001
- logger.debug(`Fetched ${comments.length} comments for pull request`);
1002
- const prevComment = comments.find(
1003
- (comment) => comment.body.includes(identifier)
1004
- );
1005
- logger.debug(
1006
- prevComment ? `Found previous comment ${prevComment.id} from Code PushUp` : "Previous Code PushUp comment not found"
1007
- );
1008
- if (prevComment) {
1009
- const updatedComment = await api.updateComment(prevComment.id, body);
1010
- logger.info(`Updated body of comment ${updatedComment.url}`);
1011
- return updatedComment.id;
1012
- }
1013
- const createdComment = await api.createComment(body);
1014
- logger.info(`Created new comment ${createdComment.url}`);
1015
- return createdComment.id;
901
+ function hasScript(packageJson, script) {
902
+ const { scripts = {} } = packageJson;
903
+ return script in scripts;
1016
904
  }
1017
- function truncateBody(body, max, logger) {
1018
- const truncateWarning = "...*[Comment body truncated]*";
1019
- if (body.length > max) {
1020
- logger.warn(`Comment body is too long. Truncating to ${max} characters.`);
1021
- return body.slice(0, max - truncateWarning.length) + truncateWarning;
1022
- }
1023
- return body;
905
+ function hasCodePushUpDependency(packageJson) {
906
+ return hasDependency(packageJson, "@code-pushup/cli");
1024
907
  }
1025
908
 
1026
- // packages/ci/src/lib/constants.ts
1027
- var DEFAULT_SETTINGS = {
1028
- monorepo: false,
1029
- projects: null,
1030
- task: "code-pushup",
1031
- bin: "npx --no-install code-pushup",
1032
- config: null,
1033
- directory: process.cwd(),
1034
- silent: false,
1035
- debug: false,
1036
- detectNewIssues: true,
1037
- logger: console
1038
- };
1039
-
1040
- // packages/ci/src/lib/git.ts
1041
- import { DiffNameStatus, simpleGit as simpleGit3 } from "simple-git";
1042
- async function listChangedFiles(refs, git = simpleGit3()) {
1043
- const statuses = [
1044
- DiffNameStatus.ADDED,
1045
- DiffNameStatus.COPIED,
1046
- DiffNameStatus.MODIFIED,
1047
- DiffNameStatus.RENAMED
1048
- ];
1049
- const { files } = await git.diffSummary([
1050
- refs.base,
1051
- refs.head,
1052
- `--diff-filter=${statuses.join("")}`,
1053
- "--find-renames",
1054
- "--find-copies"
1055
- ]);
1056
- const entries = await Promise.all(
1057
- files.filter(({ binary }) => !binary).map(({ file }) => {
1058
- const rename = parseFileRename(file);
1059
- if (rename) {
1060
- return { file: rename.curr, originalFile: rename.prev };
1061
- }
1062
- return { file };
1063
- }).map(async ({ file, originalFile }) => {
1064
- const diff = await git.diff([
1065
- "--unified=0",
1066
- refs.base,
1067
- refs.head,
1068
- "--",
1069
- file,
1070
- ...originalFile ? [originalFile] : []
1071
- ]);
1072
- const lineChanges = parseDiff(diff);
1073
- return [
1074
- file,
1075
- { ...originalFile && { originalFile }, lineChanges }
1076
- ];
1077
- })
1078
- );
1079
- return Object.fromEntries(entries);
1080
- }
1081
- function parseFileRename(file) {
1082
- const partialRenameMatch = file.match(/^(.*){(.*) => (.*)}(.*)$/);
1083
- if (partialRenameMatch) {
1084
- const [, prefix = "", prev, curr, suffix] = partialRenameMatch;
1085
- return {
1086
- prev: prefix + prev + suffix,
1087
- curr: prefix + curr + suffix
1088
- };
1089
- }
1090
- const fullRenameMatch = file.match(/^(.*) => (.*)$/);
1091
- if (fullRenameMatch) {
1092
- const [, prev = "", curr = ""] = fullRenameMatch;
1093
- return { prev, curr };
1094
- }
1095
- return null;
1096
- }
1097
- function parseDiff(diff) {
1098
- const changeSummaries = diff.match(/@@ [ \d,+-]+ @@/g);
1099
- if (changeSummaries == null) {
1100
- return [];
1101
- }
1102
- return changeSummaries.map((summary) => summary.match(/^@@ -(\d+|\d+,\d+) \+(\d+|\d+,\d+) @@$/)).filter((matches) => matches != null).map((matches) => {
1103
- const [prevLine = "", prevAdded = "1"] = matches[1].split(",");
1104
- const [currLine = "", currAdded = "1"] = matches[2].split(",");
1105
- return {
1106
- prev: {
1107
- line: Number.parseInt(prevLine, 10),
1108
- count: Number.parseInt(prevAdded, 10)
1109
- },
1110
- curr: {
1111
- line: Number.parseInt(currLine, 10),
1112
- count: Number.parseInt(currAdded, 10)
1113
- }
1114
- };
1115
- });
1116
- }
1117
- function isFileChanged(changedFiles, file) {
1118
- return file in changedFiles;
1119
- }
1120
- function adjustFileName(changedFiles, file) {
1121
- return Object.entries(changedFiles).find(
1122
- ([, { originalFile }]) => originalFile === file
1123
- )?.[0] ?? file;
1124
- }
1125
- function adjustLine(changedFiles, file, line) {
1126
- const changedFile = changedFiles[adjustFileName(changedFiles, file)];
1127
- if (!changedFile) {
1128
- return line;
1129
- }
1130
- const offset = changedFile.lineChanges.filter(({ prev }) => prev.line < line).reduce((acc, { prev, curr }) => acc + (curr.count - prev.count), 0);
1131
- return line + offset;
1132
- }
1133
-
1134
- // packages/ci/src/lib/issues.ts
1135
- function filterRelevantIssues({
1136
- currReport,
1137
- prevReport,
1138
- reportsDiff,
1139
- changedFiles
1140
- }) {
1141
- const auditsWithPlugin = [
1142
- ...reportsDiff.audits.changed,
1143
- ...reportsDiff.audits.added
1144
- ].map((auditLink) => {
1145
- const plugin = currReport.plugins.find(
1146
- ({ slug }) => slug === auditLink.plugin.slug
1147
- );
1148
- const audit = plugin?.audits.find(({ slug }) => slug === auditLink.slug);
1149
- return plugin && audit && [plugin, audit];
1150
- }).filter((ctx) => ctx != null);
1151
- const issues = auditsWithPlugin.flatMap(
1152
- ([plugin, audit]) => getAuditIssues(audit, plugin)
1153
- );
1154
- const prevIssues = prevReport.plugins.flatMap(
1155
- (plugin) => plugin.audits.flatMap((audit) => getAuditIssues(audit, plugin))
1156
- );
1157
- return issues.filter(
1158
- (issue) => isFileChanged(changedFiles, issue.source.file) && !prevIssues.some(
1159
- (prevIssue) => issuesMatch(prevIssue, issue, changedFiles)
1160
- )
1161
- ).sort(createIssuesSortCompareFn(currReport));
1162
- }
1163
- function getAuditIssues(audit, plugin) {
1164
- return audit.details?.issues?.filter((issue) => issue.source?.file != null).map((issue) => ({ ...issue, audit, plugin })) ?? [];
1165
- }
1166
- function issuesMatch(prev, curr, changedFiles) {
1167
- return prev.plugin.slug === curr.plugin.slug && prev.audit.slug === curr.audit.slug && prev.severity === curr.severity && removeDigits(prev.message) === removeDigits(curr.message) && adjustFileName(changedFiles, prev.source.file) === curr.source.file && positionsMatch(prev.source, curr.source, changedFiles);
1168
- }
1169
- function removeDigits(message) {
1170
- return message.replace(/\d+/g, "");
1171
- }
1172
- function positionsMatch(prev, curr, changedFiles) {
1173
- if (!hasPosition(prev) || !hasPosition(curr)) {
1174
- return hasPosition(prev) === hasPosition(curr);
1175
- }
1176
- return adjustedStartLinesMatch(prev, curr, changedFiles) || adjustedLineSpansMatch(prev, curr, changedFiles);
1177
- }
1178
- function hasPosition(source) {
1179
- return source.position != null;
1180
- }
1181
- function adjustedStartLinesMatch(prev, curr, changedFiles) {
1182
- return adjustLine(changedFiles, prev.file, prev.position.startLine) === curr.position.startLine;
1183
- }
1184
- function adjustedLineSpansMatch(prev, curr, changedFiles) {
1185
- if (prev.position?.endLine == null || curr.position?.endLine == null) {
1186
- return false;
1187
- }
1188
- const prevLineCount = prev.position.endLine - prev.position.startLine;
1189
- const currLineCount = curr.position.endLine - curr.position.startLine;
1190
- const currStartLineOffset = adjustLine(changedFiles, curr.file, curr.position.startLine) - curr.position.startLine;
1191
- return prevLineCount === currLineCount - currStartLineOffset;
1192
- }
1193
- function createIssuesSortCompareFn(report) {
1194
- return (a, b) => getAuditImpactValue(b, report) - getAuditImpactValue(a, report);
1195
- }
1196
- function getAuditImpactValue({ audit, plugin }, report) {
1197
- return report.categories.map((category) => {
1198
- const weights = category.refs.map((ref) => {
1199
- if (ref.plugin !== plugin.slug) {
1200
- return 0;
1201
- }
1202
- switch (ref.type) {
1203
- case "audit":
1204
- return ref.slug === audit.slug ? ref.weight : 0;
1205
- case "group":
1206
- const group = report.plugins.find(({ slug }) => slug === ref.plugin)?.groups?.find(({ slug }) => slug === ref.slug);
1207
- if (!group?.refs.length) {
1208
- return 0;
1209
- }
1210
- const groupRatio = (group.refs.find(({ slug }) => slug === audit.slug)?.weight ?? 0) / group.refs.reduce((acc, { weight }) => acc + weight, 0);
1211
- return ref.weight * groupRatio;
1212
- }
1213
- });
1214
- return weights.reduce((acc, weight) => acc + weight, 0) / category.refs.reduce((acc, { weight }) => acc + weight, 0);
1215
- }).reduce((acc, value) => acc + value, 0);
1216
- }
1217
-
1218
- // packages/ci/src/lib/monorepo/list-projects.ts
1219
- import { glob as glob2 } from "glob";
1220
- import { join as join7 } from "node:path";
1221
-
1222
- // packages/ci/src/lib/monorepo/handlers/npm.ts
1223
- import { join as join2 } from "node:path";
1224
-
1225
- // packages/ci/src/lib/monorepo/packages.ts
1226
- import { glob } from "glob";
1227
- import { basename, dirname, join } from "node:path";
1228
- async function listPackages(cwd, patterns = ["**"]) {
1229
- const files = await glob(
1230
- patterns.map((pattern) => pattern.replace(/\/?$/, "/package.json")),
1231
- { cwd }
1232
- );
1233
- return Promise.all(
1234
- files.toSorted().map(async (file) => {
1235
- const packageJson = await readJsonFile(join(cwd, file));
1236
- const directory = join(cwd, dirname(file));
1237
- const name = packageJson.name || basename(directory);
1238
- return { name, directory, packageJson };
1239
- })
1240
- );
1241
- }
1242
- async function listWorkspaces(cwd) {
1243
- const rootPackageJson = await readRootPackageJson(cwd);
1244
- const patterns = Array.isArray(rootPackageJson.workspaces) ? rootPackageJson.workspaces : rootPackageJson.workspaces?.packages;
1245
- return {
1246
- workspaces: await listPackages(cwd, patterns),
1247
- rootPackageJson
1248
- };
1249
- }
1250
- async function hasWorkspacesEnabled(cwd) {
1251
- const packageJson = await readRootPackageJson(cwd);
1252
- if (!packageJson.private) {
1253
- return false;
1254
- }
1255
- if (Array.isArray(packageJson.workspaces)) {
1256
- return packageJson.workspaces.length > 0;
1257
- }
1258
- if (typeof packageJson.workspaces === "object") {
1259
- return Boolean(packageJson.workspaces.packages?.length);
1260
- }
1261
- return false;
1262
- }
1263
- async function readRootPackageJson(cwd) {
1264
- return await readJsonFile(join(cwd, "package.json"));
1265
- }
1266
- function hasDependency(packageJson, name) {
1267
- const { dependencies = {}, devDependencies = {} } = packageJson;
1268
- return name in devDependencies || name in dependencies;
1269
- }
1270
- function hasScript(packageJson, script) {
1271
- const { scripts = {} } = packageJson;
1272
- return script in scripts;
1273
- }
1274
- function hasCodePushUpDependency(packageJson) {
1275
- return hasDependency(packageJson, "@code-pushup/cli");
1276
- }
1277
-
1278
- // packages/ci/src/lib/monorepo/handlers/npm.ts
1279
- var npmHandler = {
1280
- tool: "npm",
1281
- async isConfigured(options) {
1282
- return await fileExists(join2(options.cwd, "package-lock.json")) && await hasWorkspacesEnabled(options.cwd);
1283
- },
1284
- async listProjects(options) {
1285
- const { workspaces, rootPackageJson } = await listWorkspaces(options.cwd);
1286
- return workspaces.filter(
1287
- ({ packageJson }) => hasScript(packageJson, options.task) || hasCodePushUpDependency(packageJson) || hasCodePushUpDependency(rootPackageJson)
1288
- ).map(({ name, packageJson }) => ({
1289
- name,
1290
- bin: hasScript(packageJson, options.task) ? `npm -w ${name} run ${options.task} --` : `npm -w ${name} exec ${options.task} --`
1291
- }));
1292
- }
909
+ // packages/ci/src/lib/monorepo/handlers/npm.ts
910
+ var npmHandler = {
911
+ tool: "npm",
912
+ async isConfigured(options) {
913
+ return await fileExists(join2(options.cwd, "package-lock.json")) && await hasWorkspacesEnabled(options.cwd);
914
+ },
915
+ async listProjects(options) {
916
+ const { workspaces, rootPackageJson } = await listWorkspaces(options.cwd);
917
+ return workspaces.filter(
918
+ ({ packageJson }) => hasScript(packageJson, options.task) || hasCodePushUpDependency(packageJson) || hasCodePushUpDependency(rootPackageJson)
919
+ ).map(({ name, packageJson }) => ({
920
+ name,
921
+ bin: hasScript(packageJson, options.task) ? `npm -w ${name} run ${options.task} --` : `npm -w ${name} exec ${options.task} --`
922
+ }));
923
+ }
1293
924
  };
1294
925
 
1295
926
  // packages/ci/src/lib/monorepo/handlers/nx.ts
@@ -1525,6 +1156,381 @@ async function listProjectsByNpmPackages(args) {
1525
1156
  }));
1526
1157
  }
1527
1158
 
1159
+ // packages/ci/src/lib/monorepo/tools.ts
1160
+ var MONOREPO_TOOLS = ["nx", "turbo", "yarn", "pnpm", "npm"];
1161
+ function isMonorepoTool(value) {
1162
+ return MONOREPO_TOOLS.includes(value);
1163
+ }
1164
+
1165
+ // packages/ci/src/lib/run.ts
1166
+ import fs from "node:fs/promises";
1167
+ import path2 from "node:path";
1168
+ import { simpleGit as simpleGit4 } from "simple-git";
1169
+
1170
+ // packages/ci/src/lib/cli/persist.ts
1171
+ import path from "node:path";
1172
+ function persistCliOptions({
1173
+ directory,
1174
+ project
1175
+ }) {
1176
+ return [
1177
+ `--persist.outputDir=${path.join(directory, DEFAULT_PERSIST_OUTPUT_DIR)}`,
1178
+ `--persist.filename=${createFilename(project)}`,
1179
+ ...DEFAULT_PERSIST_FORMAT.map((format) => `--persist.format=${format}`)
1180
+ ];
1181
+ }
1182
+ function persistedCliFiles({
1183
+ directory,
1184
+ isDiff,
1185
+ project,
1186
+ formats
1187
+ }) {
1188
+ const rootDir = path.join(directory, DEFAULT_PERSIST_OUTPUT_DIR);
1189
+ const filename = isDiff ? `${createFilename(project)}-diff` : createFilename(project);
1190
+ const filePaths = (formats ?? DEFAULT_PERSIST_FORMAT).reduce(
1191
+ (acc, format) => ({
1192
+ ...acc,
1193
+ [`${format}FilePath`]: path.join(rootDir, `${filename}.${format}`)
1194
+ }),
1195
+ // eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, @typescript-eslint/consistent-type-assertions
1196
+ {}
1197
+ );
1198
+ const files = Object.values(filePaths);
1199
+ return {
1200
+ ...filePaths,
1201
+ artifactData: {
1202
+ rootDir,
1203
+ files
1204
+ }
1205
+ };
1206
+ }
1207
+ function createFilename(project) {
1208
+ if (!project) {
1209
+ return DEFAULT_PERSIST_FILENAME;
1210
+ }
1211
+ const prefix = projectToFilename(project);
1212
+ return `${prefix}-${DEFAULT_PERSIST_FILENAME}`;
1213
+ }
1214
+
1215
+ // packages/ci/src/lib/cli/commands/collect.ts
1216
+ async function runCollect({
1217
+ bin,
1218
+ config,
1219
+ directory,
1220
+ silent,
1221
+ project
1222
+ }) {
1223
+ const { stdout } = await executeProcess({
1224
+ command: bin,
1225
+ args: [
1226
+ ...config ? [`--config=${config}`] : [],
1227
+ ...persistCliOptions({ directory, project })
1228
+ ],
1229
+ cwd: directory
1230
+ });
1231
+ if (!silent) {
1232
+ console.info(stdout);
1233
+ }
1234
+ return persistedCliFiles({ directory, project });
1235
+ }
1236
+
1237
+ // packages/ci/src/lib/cli/commands/compare.ts
1238
+ async function runCompare({ before, after, label }, { bin, config, directory, silent, project }) {
1239
+ const { stdout } = await executeProcess({
1240
+ command: bin,
1241
+ args: [
1242
+ "compare",
1243
+ `--before=${before}`,
1244
+ `--after=${after}`,
1245
+ ...label ? [`--label=${label}`] : [],
1246
+ ...config ? [`--config=${config}`] : [],
1247
+ ...persistCliOptions({ directory, project })
1248
+ ],
1249
+ cwd: directory
1250
+ });
1251
+ if (!silent) {
1252
+ console.info(stdout);
1253
+ }
1254
+ return persistedCliFiles({ directory, isDiff: true, project });
1255
+ }
1256
+
1257
+ // packages/ci/src/lib/cli/commands/merge-diffs.ts
1258
+ async function runMergeDiffs(files, { bin, config, directory, silent }) {
1259
+ const { stdout } = await executeProcess({
1260
+ command: bin,
1261
+ args: [
1262
+ "merge-diffs",
1263
+ ...files.map((file) => `--files=${file}`),
1264
+ ...config ? [`--config=${config}`] : [],
1265
+ ...persistCliOptions({ directory })
1266
+ ],
1267
+ cwd: directory
1268
+ });
1269
+ if (!silent) {
1270
+ console.info(stdout);
1271
+ }
1272
+ return persistedCliFiles({ directory, isDiff: true, formats: ["md"] });
1273
+ }
1274
+
1275
+ // packages/ci/src/lib/cli/commands/print-config.ts
1276
+ async function runPrintConfig({
1277
+ bin,
1278
+ config,
1279
+ directory,
1280
+ silent
1281
+ }) {
1282
+ const { stdout } = await executeProcess({
1283
+ command: bin,
1284
+ args: [...config ? [`--config=${config}`] : [], "print-config"],
1285
+ cwd: directory
1286
+ });
1287
+ if (!silent) {
1288
+ console.info(stdout);
1289
+ }
1290
+ }
1291
+
1292
+ // packages/ci/src/lib/cli/context.ts
1293
+ function createCommandContext(settings, project) {
1294
+ return {
1295
+ project: project?.name,
1296
+ bin: project?.bin ?? settings.bin,
1297
+ directory: project?.directory ?? settings.directory,
1298
+ config: settings.config,
1299
+ silent: settings.silent
1300
+ };
1301
+ }
1302
+
1303
+ // packages/ci/src/lib/comment.ts
1304
+ import { readFile as readFile2 } from "node:fs/promises";
1305
+ async function commentOnPR(mdPath, api, logger) {
1306
+ const markdown = await readFile2(mdPath, "utf8");
1307
+ const identifier = `<!-- generated by @code-pushup/ci -->`;
1308
+ const body = truncateBody(
1309
+ `${markdown}
1310
+
1311
+ ${identifier}
1312
+ `,
1313
+ api.maxCommentChars,
1314
+ logger
1315
+ );
1316
+ const comments = await api.listComments();
1317
+ logger.debug(`Fetched ${comments.length} comments for pull request`);
1318
+ const prevComment = comments.find(
1319
+ (comment) => comment.body.includes(identifier)
1320
+ );
1321
+ logger.debug(
1322
+ prevComment ? `Found previous comment ${prevComment.id} from Code PushUp` : "Previous Code PushUp comment not found"
1323
+ );
1324
+ if (prevComment) {
1325
+ const updatedComment = await api.updateComment(prevComment.id, body);
1326
+ logger.info(`Updated body of comment ${updatedComment.url}`);
1327
+ return updatedComment.id;
1328
+ }
1329
+ const createdComment = await api.createComment(body);
1330
+ logger.info(`Created new comment ${createdComment.url}`);
1331
+ return createdComment.id;
1332
+ }
1333
+ function truncateBody(body, max, logger) {
1334
+ const truncateWarning = "...*[Comment body truncated]*";
1335
+ if (body.length > max) {
1336
+ logger.warn(`Comment body is too long. Truncating to ${max} characters.`);
1337
+ return body.slice(0, max - truncateWarning.length) + truncateWarning;
1338
+ }
1339
+ return body;
1340
+ }
1341
+
1342
+ // packages/ci/src/lib/constants.ts
1343
+ var DEFAULT_SETTINGS = {
1344
+ monorepo: false,
1345
+ projects: null,
1346
+ task: "code-pushup",
1347
+ bin: "npx --no-install code-pushup",
1348
+ config: null,
1349
+ directory: process.cwd(),
1350
+ silent: false,
1351
+ debug: false,
1352
+ detectNewIssues: true,
1353
+ logger: console
1354
+ };
1355
+
1356
+ // packages/ci/src/lib/git.ts
1357
+ import { DiffNameStatus, simpleGit as simpleGit3 } from "simple-git";
1358
+ async function listChangedFiles(refs, git = simpleGit3()) {
1359
+ const statuses = [
1360
+ DiffNameStatus.ADDED,
1361
+ DiffNameStatus.COPIED,
1362
+ DiffNameStatus.MODIFIED,
1363
+ DiffNameStatus.RENAMED
1364
+ ];
1365
+ const { files } = await git.diffSummary([
1366
+ refs.base,
1367
+ refs.head,
1368
+ `--diff-filter=${statuses.join("")}`,
1369
+ "--find-renames",
1370
+ "--find-copies"
1371
+ ]);
1372
+ const entries = await Promise.all(
1373
+ files.filter(({ binary }) => !binary).map(({ file }) => {
1374
+ const rename = parseFileRename(file);
1375
+ if (rename) {
1376
+ return { file: rename.curr, originalFile: rename.prev };
1377
+ }
1378
+ return { file };
1379
+ }).map(async ({ file, originalFile }) => {
1380
+ const diff = await git.diff([
1381
+ "--unified=0",
1382
+ refs.base,
1383
+ refs.head,
1384
+ "--",
1385
+ file,
1386
+ ...originalFile ? [originalFile] : []
1387
+ ]);
1388
+ const lineChanges = parseDiff(diff);
1389
+ return [
1390
+ file,
1391
+ { ...originalFile && { originalFile }, lineChanges }
1392
+ ];
1393
+ })
1394
+ );
1395
+ return Object.fromEntries(entries);
1396
+ }
1397
+ function parseFileRename(file) {
1398
+ const partialRenameMatch = file.match(/^(.*){(.*) => (.*)}(.*)$/);
1399
+ if (partialRenameMatch) {
1400
+ const [, prefix = "", prev, curr, suffix] = partialRenameMatch;
1401
+ return {
1402
+ prev: prefix + prev + suffix,
1403
+ curr: prefix + curr + suffix
1404
+ };
1405
+ }
1406
+ const fullRenameMatch = file.match(/^(.*) => (.*)$/);
1407
+ if (fullRenameMatch) {
1408
+ const [, prev = "", curr = ""] = fullRenameMatch;
1409
+ return { prev, curr };
1410
+ }
1411
+ return null;
1412
+ }
1413
+ function parseDiff(diff) {
1414
+ const changeSummaries = diff.match(/@@ [ \d,+-]+ @@/g);
1415
+ if (changeSummaries == null) {
1416
+ return [];
1417
+ }
1418
+ return changeSummaries.map((summary) => summary.match(/^@@ -(\d+|\d+,\d+) \+(\d+|\d+,\d+) @@$/)).filter((matches) => matches != null).map((matches) => {
1419
+ const [prevLine = "", prevAdded = "1"] = matches[1].split(",");
1420
+ const [currLine = "", currAdded = "1"] = matches[2].split(",");
1421
+ return {
1422
+ prev: {
1423
+ line: Number.parseInt(prevLine, 10),
1424
+ count: Number.parseInt(prevAdded, 10)
1425
+ },
1426
+ curr: {
1427
+ line: Number.parseInt(currLine, 10),
1428
+ count: Number.parseInt(currAdded, 10)
1429
+ }
1430
+ };
1431
+ });
1432
+ }
1433
+ function isFileChanged(changedFiles, file) {
1434
+ return file in changedFiles;
1435
+ }
1436
+ function adjustFileName(changedFiles, file) {
1437
+ return Object.entries(changedFiles).find(
1438
+ ([, { originalFile }]) => originalFile === file
1439
+ )?.[0] ?? file;
1440
+ }
1441
+ function adjustLine(changedFiles, file, line) {
1442
+ const changedFile = changedFiles[adjustFileName(changedFiles, file)];
1443
+ if (!changedFile) {
1444
+ return line;
1445
+ }
1446
+ const offset = changedFile.lineChanges.filter(({ prev }) => prev.line < line).reduce((acc, { prev, curr }) => acc + (curr.count - prev.count), 0);
1447
+ return line + offset;
1448
+ }
1449
+
1450
+ // packages/ci/src/lib/issues.ts
1451
+ function filterRelevantIssues({
1452
+ currReport,
1453
+ prevReport,
1454
+ reportsDiff,
1455
+ changedFiles
1456
+ }) {
1457
+ const auditsWithPlugin = [
1458
+ ...reportsDiff.audits.changed,
1459
+ ...reportsDiff.audits.added
1460
+ ].map((auditLink) => {
1461
+ const plugin = currReport.plugins.find(
1462
+ ({ slug }) => slug === auditLink.plugin.slug
1463
+ );
1464
+ const audit = plugin?.audits.find(({ slug }) => slug === auditLink.slug);
1465
+ return plugin && audit && [plugin, audit];
1466
+ }).filter((ctx) => ctx != null);
1467
+ const issues = auditsWithPlugin.flatMap(
1468
+ ([plugin, audit]) => getAuditIssues(audit, plugin)
1469
+ );
1470
+ const prevIssues = prevReport.plugins.flatMap(
1471
+ (plugin) => plugin.audits.flatMap((audit) => getAuditIssues(audit, plugin))
1472
+ );
1473
+ return issues.filter(
1474
+ (issue) => isFileChanged(changedFiles, issue.source.file) && !prevIssues.some(
1475
+ (prevIssue) => issuesMatch(prevIssue, issue, changedFiles)
1476
+ )
1477
+ ).sort(createIssuesSortCompareFn(currReport));
1478
+ }
1479
+ function getAuditIssues(audit, plugin) {
1480
+ return audit.details?.issues?.filter((issue) => issue.source?.file != null).map((issue) => ({ ...issue, audit, plugin })) ?? [];
1481
+ }
1482
+ function issuesMatch(prev, curr, changedFiles) {
1483
+ return prev.plugin.slug === curr.plugin.slug && prev.audit.slug === curr.audit.slug && prev.severity === curr.severity && removeDigits(prev.message) === removeDigits(curr.message) && adjustFileName(changedFiles, prev.source.file) === curr.source.file && positionsMatch(prev.source, curr.source, changedFiles);
1484
+ }
1485
+ function removeDigits(message) {
1486
+ return message.replace(/\d+/g, "");
1487
+ }
1488
+ function positionsMatch(prev, curr, changedFiles) {
1489
+ if (!hasPosition(prev) || !hasPosition(curr)) {
1490
+ return hasPosition(prev) === hasPosition(curr);
1491
+ }
1492
+ return adjustedStartLinesMatch(prev, curr, changedFiles) || adjustedLineSpansMatch(prev, curr, changedFiles);
1493
+ }
1494
+ function hasPosition(source) {
1495
+ return source.position != null;
1496
+ }
1497
+ function adjustedStartLinesMatch(prev, curr, changedFiles) {
1498
+ return adjustLine(changedFiles, prev.file, prev.position.startLine) === curr.position.startLine;
1499
+ }
1500
+ function adjustedLineSpansMatch(prev, curr, changedFiles) {
1501
+ if (prev.position?.endLine == null || curr.position?.endLine == null) {
1502
+ return false;
1503
+ }
1504
+ const prevLineCount = prev.position.endLine - prev.position.startLine;
1505
+ const currLineCount = curr.position.endLine - curr.position.startLine;
1506
+ const currStartLineOffset = adjustLine(changedFiles, curr.file, curr.position.startLine) - curr.position.startLine;
1507
+ return prevLineCount === currLineCount - currStartLineOffset;
1508
+ }
1509
+ function createIssuesSortCompareFn(report) {
1510
+ return (a, b) => getAuditImpactValue(b, report) - getAuditImpactValue(a, report);
1511
+ }
1512
+ function getAuditImpactValue({ audit, plugin }, report) {
1513
+ return report.categories.map((category) => {
1514
+ const weights = category.refs.map((ref) => {
1515
+ if (ref.plugin !== plugin.slug) {
1516
+ return 0;
1517
+ }
1518
+ switch (ref.type) {
1519
+ case "audit":
1520
+ return ref.slug === audit.slug ? ref.weight : 0;
1521
+ case "group":
1522
+ const group = report.plugins.find(({ slug }) => slug === ref.plugin)?.groups?.find(({ slug }) => slug === ref.slug);
1523
+ if (!group?.refs.length) {
1524
+ return 0;
1525
+ }
1526
+ const groupRatio = (group.refs.find(({ slug }) => slug === audit.slug)?.weight ?? 0) / group.refs.reduce((acc, { weight }) => acc + weight, 0);
1527
+ return ref.weight * groupRatio;
1528
+ }
1529
+ });
1530
+ return weights.reduce((acc, weight) => acc + weight, 0) / category.refs.reduce((acc, { weight }) => acc + weight, 0);
1531
+ }).reduce((acc, value) => acc + value, 0);
1532
+ }
1533
+
1528
1534
  // packages/ci/src/lib/run.ts
1529
1535
  async function runInCI(refs, api, options, git = simpleGit4()) {
1530
1536
  const settings = { ...DEFAULT_SETTINGS, ...options };
@@ -1704,5 +1710,7 @@ async function findNewIssues(args) {
1704
1710
  return issues;
1705
1711
  }
1706
1712
  export {
1713
+ MONOREPO_TOOLS,
1714
+ isMonorepoTool,
1707
1715
  runInCI
1708
1716
  };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@code-pushup/ci",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "CI automation logic for Code PushUp (provider-agnostic)",
5
5
  "dependencies": {
6
- "@code-pushup/models": "0.52.0",
7
- "@code-pushup/utils": "0.52.0",
6
+ "@code-pushup/models": "0.53.0",
7
+ "@code-pushup/utils": "0.53.0",
8
8
  "glob": "^10.4.5",
9
9
  "simple-git": "^3.20.0",
10
10
  "yaml": "^2.5.1"
package/src/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
+ export type { SourceFileIssue } from './lib/issues';
1
2
  export type * from './lib/models';
3
+ export { MONOREPO_TOOLS, isMonorepoTool, type MonorepoTool, } from './lib/monorepo';
2
4
  export { runInCI } from './lib/run';
@@ -1,2 +1,2 @@
1
1
  export { listMonorepoProjects } from './list-projects';
2
- export { MONOREPO_TOOLS, type MonorepoTool, type ProjectConfig } from './tools';
2
+ export { MONOREPO_TOOLS, isMonorepoTool, type MonorepoTool, type ProjectConfig, } from './tools';
@@ -16,3 +16,4 @@ export type ProjectConfig = {
16
16
  bin: string;
17
17
  directory?: string;
18
18
  };
19
+ export declare function isMonorepoTool(value: string): value is MonorepoTool;