@tallyrelease/cli 0.1.0-beta.3 → 0.1.0-beta.4

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.
@@ -14,6 +14,7 @@ export declare const affectedDocSchema: z.ZodObject<{
14
14
  "pr-merge-base": "pr-merge-base";
15
15
  "merge-group": "merge-group";
16
16
  "forge-last-success": "forge-last-success";
17
+ "local-merge-base": "local-merge-base";
17
18
  none: "none";
18
19
  }>;
19
20
  all: z.ZodBoolean;
@@ -15,6 +15,7 @@ export const affectedDocSchema = z.strictObject({
15
15
  "pr-merge-base",
16
16
  "merge-group",
17
17
  "forge-last-success",
18
+ "local-merge-base",
18
19
  "none",
19
20
  ]),
20
21
  all: z.boolean(),
@@ -35,6 +35,7 @@
35
35
  "pr-merge-base",
36
36
  "merge-group",
37
37
  "forge-last-success",
38
+ "local-merge-base",
38
39
  "none"
39
40
  ]
40
41
  },
package/dist/ci/base.d.ts CHANGED
@@ -1,14 +1,17 @@
1
- import type { GithubContext } from "./github-env.js";
1
+ import type { Env, GithubContext } from "./github-env.js";
2
2
  import type { Forge } from "../forge/forge.js";
3
3
  import type { RepoGit } from "../git/reader.js";
4
4
  import type { BaseSource, Warning } from "@tallyrelease/core";
5
5
  export interface BuildBase {
6
6
  base: string | null;
7
7
  source: BaseSource;
8
+ /** The `--base` value, or the branch of a local merge base. */
9
+ ref?: string;
8
10
  warnings: Warning[];
9
11
  }
10
12
  export interface BuildBaseInput {
11
13
  git: RepoGit;
14
+ env: Env;
12
15
  /** Resolved HEAD sha. */
13
16
  head: string;
14
17
  /** `--base`. */
@@ -17,5 +20,5 @@ export interface BuildBaseInput {
17
20
  /** Present only when a token is available. */
18
21
  forge?: Forge | undefined;
19
22
  }
20
- /** Resolves the build base from `--base`, the CI event, then the forge. */
23
+ /** Resolves the build base from `--base`, the CI event, the forge, then the local default branch. */
21
24
  export declare const resolveBuildBase: (input: BuildBaseInput) => Promise<BuildBase>;
package/dist/ci/base.js CHANGED
@@ -1,5 +1,20 @@
1
1
  import { TallyError } from "@tallyrelease/core";
2
2
  const ZERO_SHA = /^0+$/;
3
+ const CI_VARS = [
4
+ "JENKINS_URL",
5
+ "TF_BUILD",
6
+ "CODEBUILD_BUILD_ID",
7
+ "BUILDKITE",
8
+ "GITLAB_CI",
9
+ "TEAMCITY_VERSION",
10
+ "BITBUCKET_BUILD_NUMBER",
11
+ ];
12
+ const DEFAULT_REFS = ["origin/main", "origin/master", "main", "master"];
13
+ const isCi = (env) => {
14
+ const ci = env["CI"];
15
+ return ((ci !== undefined && !["", "false", "0"].includes(ci)) ||
16
+ CI_VARS.some((name) => env[name] !== undefined));
17
+ };
3
18
  const str = (value) => typeof value === "string" && value !== "" ? value : undefined;
4
19
  const field = (value, key) => typeof value === "object" && value !== null
5
20
  ? value[key]
@@ -24,7 +39,7 @@ const noLastSuccess = {
24
39
  code: "W_NO_LAST_SUCCESS",
25
40
  message: "no successful push run of this workflow on this branch",
26
41
  };
27
- /** Resolves the build base from `--base`, the CI event, then the forge. */
42
+ /** Resolves the build base from `--base`, the CI event, the forge, then the local default branch. */
28
43
  export const resolveBuildBase = async (input) => {
29
44
  const { git, head, github, forge } = input;
30
45
  // A named sha missing from a shallow clone must not silently become "everything".
@@ -84,8 +99,29 @@ export const resolveBuildBase = async (input) => {
84
99
  ? result(last, "forge-last-success")
85
100
  : result(null, "none", last);
86
101
  };
102
+ /** First of `origin/HEAD` and the common default branch names that resolves. */
103
+ const defaultRef = async () => {
104
+ const candidates = [await git.defaultBranch(), ...DEFAULT_REFS];
105
+ for (const candidate of candidates) {
106
+ if (candidate !== undefined &&
107
+ (await git.resolve(candidate)) !== undefined) {
108
+ return candidate;
109
+ }
110
+ }
111
+ return undefined;
112
+ };
87
113
  if (input.cliBase !== undefined) {
88
- return result(await required(input.cliBase, "--base"), "cli");
114
+ return {
115
+ ...result(await required(input.cliBase, "--base"), "cli"),
116
+ ref: input.cliBase,
117
+ };
118
+ }
119
+ if (github === undefined && !isCi(input.env)) {
120
+ const ref = await defaultRef();
121
+ const base = ref === undefined ? undefined : await git.mergeBase(head, ref);
122
+ return ref === undefined || base === undefined
123
+ ? result(null, "none")
124
+ : { ...result(base, "local-merge-base"), ref };
89
125
  }
90
126
  switch (github?.eventName) {
91
127
  case "pull_request": {
@@ -1,6 +1,7 @@
1
1
  import type { Session } from "../session.js";
2
2
  export interface AffectedFlags {
3
3
  base?: string;
4
+ format?: "text" | "json" | "tsv";
4
5
  json?: boolean;
5
6
  out?: string;
6
7
  githubOutput?: boolean;
@@ -3,7 +3,40 @@ import { appendFileSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { resolveBuildBase } from "../ci/base.js";
5
5
  import { affectedGithubOutput } from "../ci/outputs.js";
6
+ import { alignColumns } from "../output/columns.js";
7
+ import { count } from "../output/count.js";
8
+ import { painter } from "../output/paint.js";
6
9
  import { version } from "../version.js";
10
+ const GROUPS = [
11
+ "all",
12
+ "global",
13
+ "direct",
14
+ "dependent",
15
+ ];
16
+ const baseLabel = (base) => {
17
+ switch (base.source) {
18
+ case "cli":
19
+ return `--base ${base.ref ?? ""}`;
20
+ case "push-before":
21
+ return "push before";
22
+ case "pr-merge-base":
23
+ return "merge base with the PR target";
24
+ case "merge-group":
25
+ return "merge group base";
26
+ case "forge-last-success":
27
+ return "last successful run";
28
+ case "local-merge-base":
29
+ return `merge base with ${base.ref ?? ""}`;
30
+ case "none":
31
+ return "everything is affected";
32
+ }
33
+ };
34
+ const tsv = (doc) => [
35
+ "id\tpath\treason\tvia",
36
+ ...doc.units.map((u) => [u.id, u.path, u.reason, u.via.join(",")].join("\t")),
37
+ ]
38
+ .map((line) => `${line}\n`)
39
+ .join("");
7
40
  /** Computes the build set and writes the requested outputs. */
8
41
  export const affected = async (session, flags) => {
9
42
  const { git, github } = session;
@@ -15,6 +48,7 @@ export const affected = async (session, flags) => {
15
48
  const base = await resolveBuildBase({
16
49
  git,
17
50
  head,
51
+ env: session.env,
18
52
  cliBase: flags.base,
19
53
  github,
20
54
  forge: session.forge,
@@ -42,13 +76,46 @@ export const affected = async (session, flags) => {
42
76
  }
43
77
  appendFileSync(path, affectedGithubOutput(doc));
44
78
  }
45
- if (flags.json) {
79
+ const format = flags.format ?? (flags.json ? "json" : "text");
80
+ if (format === "json") {
46
81
  session.stdout.write(json);
47
82
  return;
48
83
  }
49
- session.stdout.write(`base: ${doc.build.base ?? "none"} (${doc.build.baseSource})\n`);
50
- for (const u of doc.units) {
51
- const via = u.via.length > 0 ? ` via ${u.via.join(", ")}` : "";
52
- session.stdout.write(`${u.id}\t${u.path}\t${u.reason}${via}\n`);
84
+ if (format === "tsv") {
85
+ session.stdout.write(tsv(doc));
86
+ return;
87
+ }
88
+ const paint = painter(session.stdout);
89
+ const released = new Set(workspace.units.filter((u) => u.version !== "none").map((u) => u.id));
90
+ const rows = alignColumns(doc.units.map((u) => [
91
+ u.id,
92
+ u.path,
93
+ u.via.length > 0 ? `via ${u.via.join(", ")}` : "",
94
+ released.has(u.id) ? "released" : "",
95
+ ]), { indent: 2, styles: [undefined, "dim", "dim", "cyan"] }, paint);
96
+ const sha = doc.build.base?.slice(0, 7) ?? "none";
97
+ const lines = [
98
+ `${paint("bold", "base")} ${paint("yellow", sha)} ${paint("dim", `(${baseLabel(base)})`)}`,
99
+ "",
100
+ ];
101
+ for (const group of GROUPS) {
102
+ const members = rows.filter((_, i) => doc.units[i]?.reason === group);
103
+ if (members.length > 0) {
104
+ lines.push(paint("bold", `${group} (${String(members.length)})`));
105
+ if (group === "global") {
106
+ lines.push(` changed: ${doc.build.globalInputs.join(", ")}`);
107
+ }
108
+ lines.push(...members);
109
+ }
110
+ }
111
+ if (doc.units.length > 0) {
112
+ lines.push("");
53
113
  }
114
+ const outside = doc.unattributed.length > 0
115
+ ? `, ${count(doc.unattributed.length, "file")} outside any unit`
116
+ : "";
117
+ lines.push(doc.units.length > 0
118
+ ? `${String(doc.units.length)} of ${count(workspace.units.length, "unit")} affected${outside}`
119
+ : `no units affected${outside}`);
120
+ session.stdout.write(`${lines.join("\n")}\n`);
54
121
  };
@@ -1,3 +1,4 @@
1
+ import { count } from "../output/count.js";
1
2
  /** Validates config, plugins, units and graph. */
2
3
  export const check = async (session) => {
3
4
  const workspace = await session.workspace();
@@ -6,5 +7,5 @@ export const check = async (session) => {
6
7
  .filter((u) => u.version !== "none")
7
8
  .map((u) => u.id);
8
9
  const list = released.length > 0 ? `: ${released.join(", ")}` : "";
9
- session.stdout.write(`ok: ${String(workspace.units.length)} units (${String(released.length)} released${list}), ${String(workspace.graph.edges.length)} edges\n`);
10
+ session.stdout.write(`ok: ${count(workspace.units.length, "unit")} (${String(released.length)} released${list}), ${count(workspace.graph.edges.length, "edge")}\n`);
10
11
  };
@@ -1,3 +1,7 @@
1
+ import { alignColumns } from "../output/columns.js";
2
+ import { count } from "../output/count.js";
3
+ import { painter } from "../output/paint.js";
4
+ import { renderTree } from "../output/tree.js";
1
5
  import { renderMermaid } from "../render/mermaid.js";
2
6
  /** Prints units and edges. */
3
7
  export const graph = async (session, flags) => {
@@ -24,10 +28,36 @@ export const graph = async (session, flags) => {
24
28
  session.stdout.write(`${JSON.stringify({ repo: workspace.repo, units, edges }, null, 2)}\n`);
25
29
  return;
26
30
  }
27
- for (const u of units) {
28
- session.stdout.write(`${u.id}\t${u.path}\t${u.plugin}\t${u.version}\n`);
31
+ if (units.length === 0) {
32
+ session.stdout.write("no units\n");
33
+ return;
29
34
  }
30
- for (const e of edges) {
31
- session.stdout.write(`${e.from} -> ${e.to}${e.pinned ? " (pinned)" : ""}\n`);
35
+ const paint = painter(session.stdout);
36
+ const table = [
37
+ ["UNIT", "PATH", "PLUGIN", "RELEASE"],
38
+ ...units.map((u) => [
39
+ u.id,
40
+ u.path,
41
+ u.plugin,
42
+ u.version === "none" ? "-" : u.version,
43
+ ]),
44
+ ];
45
+ // Header is aligned unstyled so column styles do not apply to it.
46
+ const [header = ""] = alignColumns(table);
47
+ const [, ...rows] = alignColumns(table, { styles: [undefined, "dim"] }, paint);
48
+ const lines = [paint("bold", header), ...rows];
49
+ const tree = renderTree(units.map((u) => u.id), (id) => workspace.graph.dependents.get(id) ?? []);
50
+ if (edges.length > 0) {
51
+ lines.push("", paint("bold", "dependents"), ...tree.lines);
32
52
  }
53
+ const released = units.filter((u) => u.version !== "none").length;
54
+ const legend = [
55
+ ...(tree.shared ? ["(*) expanded above"] : []),
56
+ ...(tree.cycle ? ["(cycle) edge back into the current path"] : []),
57
+ ];
58
+ lines.push("", [
59
+ `${count(units.length, "unit")} (${String(released)} released), ${count(edges.length, "edge")}`,
60
+ ...legend,
61
+ ].join("; "));
62
+ session.stdout.write(`${lines.join("\n")}\n`);
33
63
  };
@@ -8,6 +8,8 @@ export interface RepoGit extends GitReader {
8
8
  isShallow: () => MaybePromise<boolean>;
9
9
  /** Current branch, undefined when detached. */
10
10
  branch: () => MaybePromise<string | undefined>;
11
+ /** Target of `origin/HEAD`, for example `origin/main`; undefined if unset. */
12
+ defaultBranch: () => MaybePromise<string | undefined>;
11
13
  }
12
14
  /** `RepoGit` backed by the `git` executable in `cwd`. */
13
15
  export declare const gitReader: (cwd: string, debug?: (message: string) => void) => RepoGit;
@@ -110,5 +110,9 @@ export const gitReader = (cwd, debug) => {
110
110
  const result = await run(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], debug);
111
111
  return result.code === 0 ? result.stdout.trim() : undefined;
112
112
  },
113
+ defaultBranch: async () => {
114
+ const result = await run(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], debug);
115
+ return result.code === 0 ? result.stdout.trim() : undefined;
116
+ },
113
117
  };
114
118
  };
package/dist/main.js CHANGED
@@ -4,6 +4,7 @@ import { inspect } from "node:util";
4
4
  import { affected } from "./commands/affected.js";
5
5
  import { check } from "./commands/check.js";
6
6
  import { graph } from "./commands/graph.js";
7
+ import { painter } from "./output/paint.js";
7
8
  import { createSession } from "./session.js";
8
9
  import { version } from "./version.js";
9
10
  /** Runs the cli; returns the exit code (0 ok, 1 error, 2 usage). */
@@ -46,10 +47,18 @@ export const main = async (argv, deps) => {
46
47
  .command("affected")
47
48
  .description("compute the build set")
48
49
  .option("--base <ref>", "diff against this ref")
49
- .option("--json", "print the affected document")
50
+ .option("--json", "print the affected document (same as --format json)")
51
+ .addOption(new Option("--format <format>", "output format").choices([
52
+ "text",
53
+ "json",
54
+ "tsv",
55
+ ]))
50
56
  .option("--out <file>", "write the affected document to a file")
51
57
  .option("--github-output", "append outputs to $GITHUB_OUTPUT")
52
- .action(async (flags) => {
58
+ .action(async (flags, cmd) => {
59
+ if (flags.json && flags.format !== undefined) {
60
+ cmd.error("--json and --format cannot be combined", { exitCode: 2 });
61
+ }
53
62
  await affected(session(), flags);
54
63
  });
55
64
  try {
@@ -60,11 +69,12 @@ export const main = async (argv, deps) => {
60
69
  if (error instanceof CommanderError) {
61
70
  return error.exitCode === 0 ? 0 : 2;
62
71
  }
72
+ const paint = painter(deps.stderr);
63
73
  if (error instanceof TallyError) {
64
- deps.stderr.write(`${error.code}: ${error.message}\n`);
74
+ deps.stderr.write(`${paint("red", `error[${error.code}]`)}: ${error.message}\n`);
65
75
  }
66
76
  else {
67
- deps.stderr.write(`E_INTERNAL: ${error instanceof Error ? error.message : String(error)}\n`);
77
+ deps.stderr.write(`${paint("red", "error[E_INTERNAL]")}: ${error instanceof Error ? error.message : String(error)}\n`);
68
78
  if (program.opts().verbose) {
69
79
  deps.stderr.write(`${inspect(error)}\n`);
70
80
  }
@@ -0,0 +1,10 @@
1
+ import type { Format, Paint } from "./paint.js";
2
+ export interface ColumnOptions {
3
+ indent?: number;
4
+ styles?: readonly (Format | undefined)[];
5
+ }
6
+ /**
7
+ * Pads cells to aligned columns; widths come from the plain text, styles apply
8
+ * per cell. Columns that are empty in every row are dropped.
9
+ */
10
+ export declare const alignColumns: (rows: readonly (readonly string[])[], opts?: ColumnOptions, paint?: Paint) => string[];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Pads cells to aligned columns; widths come from the plain text, styles apply
3
+ * per cell. Columns that are empty in every row are dropped.
4
+ */
5
+ export const alignColumns = (rows, opts = {}, paint = (_format, text) => text) => {
6
+ const widths = [];
7
+ for (const row of rows) {
8
+ row.forEach((cell, i) => {
9
+ widths[i] = Math.max(widths[i] ?? 0, cell.length);
10
+ });
11
+ }
12
+ const indent = " ".repeat(opts.indent ?? 0);
13
+ return rows.map((row) => {
14
+ const cells = [...row];
15
+ while (cells.at(-1) === "") {
16
+ cells.pop();
17
+ }
18
+ return (indent +
19
+ cells
20
+ .map((cell, i) => {
21
+ if (widths[i] === 0) {
22
+ return undefined;
23
+ }
24
+ const style = opts.styles?.[i];
25
+ const text = style && cell !== "" ? paint(style, cell) : cell;
26
+ const pad = i === cells.length - 1 ? 0 : (widths[i] ?? 0) - cell.length;
27
+ return text + " ".repeat(pad);
28
+ })
29
+ .filter((cell) => cell !== undefined)
30
+ .join(" "));
31
+ });
32
+ };
@@ -0,0 +1,2 @@
1
+ /** `1 unit`, `2 units`. */
2
+ export declare const count: (n: number, noun: string) => string;
@@ -0,0 +1,2 @@
1
+ /** `1 unit`, `2 units`. */
2
+ export const count = (n, noun) => `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
@@ -0,0 +1,6 @@
1
+ import { styleText } from "node:util";
2
+ import type { Writer } from "../session.js";
3
+ export type Format = Parameters<typeof styleText>[0];
4
+ export type Paint = (format: Format, text: string) => string;
5
+ /** Styles text for `stream`; Node decides on color for real streams, other writers stay plain. */
6
+ export declare const painter: (stream: Writer) => Paint;
@@ -0,0 +1,10 @@
1
+ import { Stream } from "node:stream";
2
+ import { styleText } from "node:util";
3
+ /** Styles text for `stream`; Node decides on color for real streams, other writers stay plain. */
4
+ export const painter = (stream) => stream instanceof Stream
5
+ ? (format, text) =>
6
+ // styleText validates the stream at runtime.
7
+ styleText(format, text, {
8
+ stream: stream,
9
+ })
10
+ : (_format, text) => text;
@@ -0,0 +1,12 @@
1
+ export interface Tree {
2
+ lines: string[];
3
+ /** A `(*)` marker was printed. */
4
+ shared: boolean;
5
+ /** A `(cycle)` marker was printed. */
6
+ cycle: boolean;
7
+ }
8
+ /**
9
+ * Renders `children` as trees from the units without dependencies, in id order.
10
+ * Units left unvisited (pure cycles) become extra roots, smallest id first.
11
+ */
12
+ export declare const renderTree: (ids: readonly string[], children: (id: string) => readonly string[]) => Tree;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Renders `children` as trees from the units without dependencies, in id order.
3
+ * Units left unvisited (pure cycles) become extra roots, smallest id first.
4
+ */
5
+ export const renderTree = (ids, children) => {
6
+ const tree = { lines: [], shared: false, cycle: false };
7
+ const visited = new Set();
8
+ const path = new Set();
9
+ const visit = (id, branch, prefix) => {
10
+ const kids = children(id);
11
+ if (path.has(id)) {
12
+ tree.lines.push(`${branch}${id} (cycle)`);
13
+ tree.cycle = true;
14
+ return;
15
+ }
16
+ if (visited.has(id) && kids.length > 0) {
17
+ tree.lines.push(`${branch}${id} (*)`);
18
+ tree.shared = true;
19
+ return;
20
+ }
21
+ tree.lines.push(`${branch}${id}`);
22
+ visited.add(id);
23
+ path.add(id);
24
+ kids.forEach((kid, i) => {
25
+ const last = i === kids.length - 1;
26
+ visit(kid, `${prefix}${last ? "└── " : "├── "}`, `${prefix}${last ? " " : "│ "}`);
27
+ });
28
+ path.delete(id);
29
+ };
30
+ const inDegree = new Map(ids.map((id) => [id, 0]));
31
+ for (const id of ids) {
32
+ for (const kid of children(id)) {
33
+ inDegree.set(kid, (inDegree.get(kid) ?? 0) + 1);
34
+ }
35
+ }
36
+ for (const id of ids) {
37
+ if (inDegree.get(id) === 0) {
38
+ visit(id, "", "");
39
+ }
40
+ }
41
+ for (let root = ids.find((id) => !visited.has(id)); root !== undefined; root = ids.find((id) => !visited.has(id))) {
42
+ visit(root, "", "");
43
+ }
44
+ return tree;
45
+ };
package/dist/session.js CHANGED
@@ -6,6 +6,7 @@ import { githubForge } from "./forge/github.js";
6
6
  import { nodeFs } from "./fs-ctx.js";
7
7
  import { git as runGit } from "./git/exec.js";
8
8
  import { gitReader } from "./git/reader.js";
9
+ import { painter } from "./output/paint.js";
9
10
  import { builtinPlugins } from "./plugins/index.js";
10
11
  export const createSession = (deps, opts) => {
11
12
  const root = resolve(deps.cwd, opts.cwd ?? ".");
@@ -69,8 +70,9 @@ export const createSession = (deps, opts) => {
69
70
  });
70
71
  },
71
72
  warn: (warnings) => {
73
+ const paint = painter(deps.stderr);
72
74
  for (const w of warnings) {
73
- deps.stderr.write(`${w.code}: ${w.plugin ? `${w.plugin}: ` : ""}${w.message}\n`);
75
+ deps.stderr.write(`${paint("yellow", `warning[${w.code}]`)}: ${w.plugin ? `${w.plugin}: ` : ""}${w.message}\n`);
74
76
  }
75
77
  },
76
78
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyrelease/cli",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Command line interface of tallyrelease",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,7 +47,7 @@
47
47
  "prettier": "@abinnovision/prettier-config",
48
48
  "dependencies": {
49
49
  "@octokit/rest": "^22.0.1",
50
- "@tallyrelease/core": "^0.1.0-beta.2",
50
+ "@tallyrelease/core": "^0.1.0-beta.3",
51
51
  "commander": "^15.0.0",
52
52
  "tinyglobby": "^0.2.17",
53
53
  "yaml": "^2.9.1",