repokeeper 0.5.0 → 0.6.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.
Files changed (40) hide show
  1. package/.github/workflows/commitlint.yml +49 -0
  2. package/.github/workflows/release-please.yml +59 -0
  3. package/.github/workflows/repokeeper-check.yml +30 -0
  4. package/.github/workflows/stack-dart.yml +72 -0
  5. package/.github/workflows/stack-dotnet.yml +68 -0
  6. package/.github/workflows/stack-go.yml +59 -0
  7. package/.github/workflows/stack-java.yml +70 -0
  8. package/.github/workflows/stack-node.yml +85 -0
  9. package/.github/workflows/stack-php.yml +66 -0
  10. package/.github/workflows/stack-python.yml +70 -0
  11. package/.github/workflows/stack-ruby.yml +65 -0
  12. package/.github/workflows/stack-rust.yml +60 -0
  13. package/.github/workflows/stack-script.yml +87 -0
  14. package/README.md +59 -105
  15. package/dist/cli.js +48 -9
  16. package/dist/commands/context.js +19 -2
  17. package/dist/commands/eject.js +74 -0
  18. package/dist/commands/gitlab.js +46 -0
  19. package/dist/commands/init.js +104 -10
  20. package/dist/commands/presets.js +114 -0
  21. package/dist/commands/report.js +2 -2
  22. package/dist/commands/update.js +3 -2
  23. package/dist/config/schema.js +40 -1
  24. package/dist/duplicates.js +2 -2
  25. package/dist/errors.js +1 -1
  26. package/dist/gitlab/api.js +50 -0
  27. package/dist/gitlab/settings.js +181 -0
  28. package/dist/model.js +8 -0
  29. package/dist/modules/commits.js +3 -2
  30. package/dist/modules/deps.js +1 -1
  31. package/dist/modules/health.js +3 -3
  32. package/dist/modules/hooks.js +7 -5
  33. package/dist/modules/release.js +18 -5
  34. package/dist/platforms/github.js +70 -12
  35. package/dist/platforms/gitlab.js +25 -4
  36. package/dist/stacks/index.js +56 -0
  37. package/dist/stacks/support.js +19 -0
  38. package/dist/templates.js +13 -1
  39. package/dist/version.js +2 -0
  40. package/package.json +6 -2
@@ -0,0 +1,46 @@
1
+ import { loadConfig } from "../config/load.js";
2
+ import { UsageError } from "../errors.js";
3
+ import { repoInfo } from "../git.js";
4
+ import { gitlabRestApi, resolveGitlabToken } from "../gitlab/api.js";
5
+ import { planGitlab } from "../gitlab/settings.js";
6
+ /** `repokeeper gitlab apply`: diff the project's GitLab settings against `gitlab:` and apply on confirmation. */
7
+ export async function gitlabApplyCommand(root, options, io) {
8
+ const config = await loadConfig(root);
9
+ if (config.platform !== "gitlab")
10
+ throw new UsageError(`this repository uses the ${config.platform} platform`);
11
+ const repo = await repoInfo(root, "gitlab");
12
+ if (!repo.owner)
13
+ throw new UsageError("the origin remote is not a GitLab project");
14
+ const path = `${repo.owner}/${repo.name}`;
15
+ const host = repo.host ?? "gitlab.com";
16
+ const api = io.gitlabApi ?? gitlabRestApi(await resolveGitlabToken(host), host);
17
+ // the jobs that wait for a secret: reported when it is missing, never created
18
+ const secrets = [
19
+ ...(config.modules.release ? [{ key: "GITLAB_TOKEN", job: "release" }] : []),
20
+ ...(config.modules.deps ? [{ key: "RENOVATE_TOKEN", job: "renovate" }] : []),
21
+ ];
22
+ const { changes, notes } = await planGitlab(api, path, config.gitlab ?? {}, secrets);
23
+ if (changes.length === 0)
24
+ io.out(`GitLab settings of ${path} match .repokeeper.yml`);
25
+ for (const change of changes)
26
+ io.out(`change ${change.setting}: ${change.from} -> ${change.to}`);
27
+ for (const note of notes)
28
+ io.out(`note: ${note}`);
29
+ if (changes.length === 0)
30
+ return 0;
31
+ if (options.dryRun) {
32
+ io.out("dry run: nothing applied");
33
+ return 0;
34
+ }
35
+ const question = `apply ${changes.length} change(s) to ${path}?`;
36
+ const confirmed = options.yes || (io.confirm ? await io.confirm(question) : false);
37
+ if (!confirmed) {
38
+ io.out("nothing applied; pass --yes to apply");
39
+ return 1;
40
+ }
41
+ for (const change of changes) {
42
+ await change.apply(api);
43
+ io.out(`applied ${change.setting}`);
44
+ }
45
+ return 0;
46
+ }
@@ -1,21 +1,23 @@
1
1
  import { existsSync } from "node:fs";
2
- import { writeFile } from "node:fs/promises";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { CONFIG_FILE, loadConfig, renderConfig } from "../config/load.js";
4
+ import { CONFIG_FILE, loadConfig, renderConfig, setStandard } from "../config/load.js";
5
5
  import { defaultConfig } from "../config/types.js";
6
6
  import { UsageError } from "../errors.js";
7
7
  import { dirtyPaths, gitUserName, remoteDefaultBranch, remoteHost, repoInfo } from "../git.js";
8
8
  import { outputId } from "../model.js";
9
9
  import { planOutputs } from "../plan.js";
10
10
  import { platformFor } from "../platforms/index.js";
11
- import { detectStacks } from "../stacks/index.js";
11
+ import { detectStackLayout } from "../stacks/index.js";
12
12
  import { applySync } from "../sync/apply.js";
13
13
  import { hashText } from "../sync/hash.js";
14
- import { targetOf, writeLock } from "../sync/lock.js";
14
+ import { readLock, targetOf, writeLock } from "../sync/lock.js";
15
15
  import { readCurrent } from "../sync/state.js";
16
16
  import { computeSync, pathsToWrite } from "../sync/sync.js";
17
17
  import { STANDARD_VERSION } from "../version.js";
18
+ import { assertSupportedStandard } from "./check.js";
18
19
  import { buildContext } from "./context.js";
20
+ import { applyPreset, chooseModules } from "./presets.js";
19
21
  import { nextSteps, printResult, printWarnings } from "./report.js";
20
22
  /** A dirty path is still safe to write when every part repokeeper manages in it is exactly what it last wrote. */
21
23
  async function onlyRepokeeperChanges(root, path, lock) {
@@ -49,15 +51,33 @@ export async function initCommand(root, options, io) {
49
51
  if (options.relock)
50
52
  return relock(root, options, io);
51
53
  if (existsSync(join(root, CONFIG_FILE))) {
52
- throw new UsageError(`${CONFIG_FILE} already exists; run \`repokeeper update\` or \`repokeeper check\``);
54
+ // a lock means the standard was applied here before; without one the file was written by hand
55
+ if ((await readLock(root)) !== null) {
56
+ throw new UsageError(`${CONFIG_FILE} already exists; run \`repokeeper update\` or \`repokeeper check\``);
57
+ }
58
+ return initFromConfig(root, options, io);
53
59
  }
54
- const stacks = options.stacks.length > 0 ? options.stacks : await detectStacks(root);
60
+ const layout = options.stacks.length > 0
61
+ ? { stacks: options.stacks, directories: options.stackDirectories ?? {}, skipped: [] }
62
+ : await detectStackLayout(root);
63
+ const { stacks } = layout;
55
64
  if (stacks.length === 0)
56
65
  throw new UsageError("no supported stack detected; pass --stack node");
66
+ for (const id of stacks) {
67
+ if (layout.directories[id] !== undefined)
68
+ io.out(`found ${id} in ${layout.directories[id]}/`);
69
+ }
70
+ for (const { stack, directory } of layout.skipped) {
71
+ io.out(`note: ${directory}/ also holds a ${stack} project; one folder per stack is managed, so it is left out`);
72
+ }
57
73
  const platform = options.platform ?? ((await remoteHost(root))?.includes("gitlab") ? "gitlab" : "github");
58
74
  const repo = await repoInfo(root, platform);
59
75
  const holder = (await gitUserName(root)) ?? repo.owner ?? "the project authors";
60
- const config = defaultConfig({
76
+ const { confirm } = io;
77
+ if (options.interactive && confirm === undefined) {
78
+ throw new UsageError("--interactive needs a terminal to ask its questions in");
79
+ }
80
+ const defaults = defaultConfig({
61
81
  stacks,
62
82
  standard: STANDARD_VERSION,
63
83
  copyright: `${new Date().getFullYear()} ${holder}`,
@@ -65,12 +85,29 @@ export async function initCommand(root, options, io) {
65
85
  codeowners: repo.owner ? [`@${repo.owner}`] : [],
66
86
  platform,
67
87
  });
88
+ for (const id of stacks) {
89
+ const directory = layout.directories[id];
90
+ if (directory !== undefined)
91
+ defaults.stack_options[id] = { directory };
92
+ }
68
93
  const branch = await remoteDefaultBranch(root);
69
94
  if (branch && branch !== "main")
70
- config[platform] = { default_branch: branch };
71
- const ctx = await buildContext(root, config, repo);
95
+ defaults[platform] = { default_branch: branch };
96
+ const preset = options.preset ?? "standard";
97
+ let config = applyPreset(defaults, preset);
98
+ const plan = async (candidate) => {
99
+ const ctx = await buildContext(root, candidate, repo);
100
+ return { ctx, outputs: planOutputs(ctx) };
101
+ };
102
+ if (options.interactive && confirm !== undefined) {
103
+ // the default health settings stay at hand for a preset that left the module out
104
+ const health = defaults.modules.health;
105
+ if (health)
106
+ config = await chooseModules(config, health, plan, { ...io, confirm });
107
+ }
108
+ const { ctx, outputs } = await plan(config);
72
109
  const adopt = options.adoptAll ? "all" : new Set(options.adopt);
73
- const result = await computeSync(root, planOutputs(ctx), null, { adopt, accept: new Set() });
110
+ const result = await computeSync(root, outputs, null, { adopt, accept: new Set() });
74
111
  if (!options.dryRun)
75
112
  await guardUncommitted(root, result, options.force);
76
113
  printResult(io, result);
@@ -78,12 +115,69 @@ export async function initCommand(root, options, io) {
78
115
  io.out("dry run: nothing written");
79
116
  return 0;
80
117
  }
118
+ if (options.interactive && confirm !== undefined && !(await confirm("Write these files?", true))) {
119
+ io.out("nothing written");
120
+ return 0;
121
+ }
81
122
  await writeFile(join(root, CONFIG_FILE), renderConfig(config));
82
123
  await applySync(root, result, null, STANDARD_VERSION);
83
124
  io.out(`applied standard ${STANDARD_VERSION}; wrote ${CONFIG_FILE}`);
84
125
  await printWarnings(root, ctx, io);
85
126
  for (const step of await nextSteps(root, ctx, result))
86
127
  io.out(`next: ${step}`);
128
+ if (config.github?.protect || config.github?.security) {
129
+ io.out("next: run `repokeeper github apply` to put the branch protection and security settings in place");
130
+ }
131
+ io.out(`next: commit with "chore(repokeeper): apply standard ${STANDARD_VERSION}"`);
132
+ return 0;
133
+ }
134
+ /**
135
+ * Applies a `.repokeeper.yml` that was written before repokeeper ran here, by hand or by the
136
+ * playground: nothing is detected or asked, the file says it all.
137
+ */
138
+ async function initFromConfig(root, options, io) {
139
+ const given = [
140
+ options.stacks.length > 0 ? "--stack" : undefined,
141
+ options.platform !== undefined ? "--platform" : undefined,
142
+ options.preset !== undefined ? "--preset" : undefined,
143
+ ].filter((name) => name !== undefined);
144
+ if (given.length > 0) {
145
+ throw new UsageError(`${CONFIG_FILE} already exists and is applied as it is written; leave out ${given.join(", ")}, or remove the file`);
146
+ }
147
+ const { confirm } = io;
148
+ if (options.interactive && confirm === undefined) {
149
+ throw new UsageError("--interactive needs a terminal to ask its questions in");
150
+ }
151
+ const config = await loadConfig(root);
152
+ assertSupportedStandard(config.standard);
153
+ io.out(`using the ${CONFIG_FILE} that is already here`);
154
+ const ctx = await buildContext(root, { ...config, standard: STANDARD_VERSION });
155
+ const adopt = options.adoptAll ? "all" : new Set(options.adopt);
156
+ const result = await computeSync(root, planOutputs(ctx), null, { adopt, accept: new Set() });
157
+ if (!options.dryRun)
158
+ await guardUncommitted(root, result, options.force);
159
+ printResult(io, result);
160
+ if (options.dryRun) {
161
+ io.out("dry run: nothing written");
162
+ return 0;
163
+ }
164
+ // the module questions of a detected init have their answers in the file already
165
+ if (options.interactive && confirm !== undefined && !(await confirm("Write these files?", true))) {
166
+ io.out("nothing written");
167
+ return 0;
168
+ }
169
+ if (config.standard !== STANDARD_VERSION) {
170
+ const path = join(root, CONFIG_FILE);
171
+ await writeFile(path, setStandard(await readFile(path, "utf8"), STANDARD_VERSION));
172
+ }
173
+ await applySync(root, result, null, STANDARD_VERSION);
174
+ io.out(`applied standard ${STANDARD_VERSION}`);
175
+ await printWarnings(root, ctx, io);
176
+ for (const step of await nextSteps(root, ctx, result))
177
+ io.out(`next: ${step}`);
178
+ if (config.github?.protect || config.github?.security) {
179
+ io.out("next: run `repokeeper github apply` to put the branch protection and security settings in place");
180
+ }
87
181
  io.out(`next: commit with "chore(repokeeper): apply standard ${STANDARD_VERSION}"`);
88
182
  return 0;
89
183
  }
@@ -0,0 +1,114 @@
1
+ export const PRESETS = ["essential", "standard", "strict"];
2
+ /** One line per preset, for the usage text and the interactive question. */
3
+ export const PRESET_SUMMARY = {
4
+ essential: "editor settings, .gitignore and CI only",
5
+ standard: "everything except the drift check (the default)",
6
+ strict: "everything, the drift check, and branch protection with a required review",
7
+ };
8
+ /** The order the modules are offered in: files first, then the automation built on them. */
9
+ export const MODULE_IDS = [
10
+ "editorconfig",
11
+ "gitignore",
12
+ "commits",
13
+ "hooks",
14
+ "ci",
15
+ "release",
16
+ "deps",
17
+ "health",
18
+ "drift",
19
+ ];
20
+ /**
21
+ * Applies a preset to a freshly built default configuration.
22
+ *
23
+ * A preset only switches on what repokeeper really does. There is no tier that promises code or
24
+ * secret scanning, because no module writes it.
25
+ */
26
+ export function applyPreset(config, preset) {
27
+ if (preset === "standard")
28
+ return config;
29
+ if (preset === "essential") {
30
+ return {
31
+ ...config,
32
+ modules: {
33
+ ...config.modules,
34
+ commits: false,
35
+ hooks: false,
36
+ release: false,
37
+ deps: false,
38
+ drift: false,
39
+ health: false,
40
+ },
41
+ };
42
+ }
43
+ const strict = { ...config, modules: { ...config.modules, drift: true } };
44
+ // repository settings exist on GitHub only; they take effect with `repokeeper github apply`
45
+ if (config.platform === "github") {
46
+ strict.github = {
47
+ ...config.github,
48
+ security: { dependabot_alerts: true, dependabot_security_updates: true },
49
+ protect: {
50
+ require_pull_request: true,
51
+ required_approvals: 1,
52
+ allow_force_push: false,
53
+ ...(config.modules.commits ? { required_checks: ["commits / commitlint"] } : {}),
54
+ },
55
+ };
56
+ }
57
+ return strict;
58
+ }
59
+ /** The files each module would write to, so a question about it can name them. */
60
+ function filesByModule(outputs) {
61
+ const files = new Map();
62
+ for (const output of outputs) {
63
+ // one line per file, however many of its keys or blocks the module manages
64
+ files.set(output.module, (files.get(output.module) ?? new Set()).add(output.path));
65
+ }
66
+ return new Map([...files].map(([module, paths]) => [module, [...paths].sort()]));
67
+ }
68
+ /** The drift check is a job of the CI workflow, planned by the ci module. */
69
+ const plannedBy = (module) => (module === "drift" ? "ci" : module);
70
+ /**
71
+ * Asks, module by module, whether to apply it, naming the files it would write. The answer that
72
+ * Enter gives is the preset's.
73
+ *
74
+ * @param plan Plans the outputs for a configuration; called with every module switched on, so that
75
+ * a module the preset leaves out can still say what it would add.
76
+ */
77
+ export async function chooseModules(config, health, plan, io) {
78
+ const everything = {
79
+ ...config,
80
+ modules: {
81
+ editorconfig: true,
82
+ commits: true,
83
+ hooks: true,
84
+ ci: true,
85
+ release: true,
86
+ deps: true,
87
+ gitignore: true,
88
+ drift: true,
89
+ health,
90
+ },
91
+ };
92
+ const files = filesByModule((await plan(everything)).outputs);
93
+ const modules = { ...config.modules };
94
+ io.out("Choose what to apply. Enter keeps the answer in capitals.");
95
+ for (const id of MODULE_IDS) {
96
+ const listed = id === "drift"
97
+ ? [".github/workflows/ci.yml (a job that fails a pull request when a managed file was edited by hand)"]
98
+ : (files.get(plannedBy(id)) ?? []);
99
+ if (listed.length === 0)
100
+ continue;
101
+ const shown = listed.length > 6 ? [...listed.slice(0, 6), `and ${listed.length - 6} more`] : listed;
102
+ io.out("");
103
+ io.out(`${id}:`);
104
+ for (const file of shown)
105
+ io.out(` ${file}`);
106
+ const wanted = await io.confirm(`Apply ${id}?`, Boolean(modules[id]));
107
+ if (id === "health")
108
+ modules.health = wanted ? health : false;
109
+ else
110
+ modules[id] = wanted;
111
+ }
112
+ io.out("");
113
+ return { ...config, modules };
114
+ }
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { parse } from "yaml";
4
4
  import { duplicateTestRuns } from "../duplicates.js";
5
5
  import { crlfTrackedFiles } from "../git.js";
6
- import { describeOutput } from "../model.js";
6
+ import { describeOutput, nodeAtRoot } from "../model.js";
7
7
  import { overriddenAttributes } from "../sync/attributes.js";
8
8
  import { TOOL_VERSIONS } from "../version.js";
9
9
  /** How to name the output under `owned`: the path, or `path#key` for one key of a shared file. */
@@ -47,7 +47,7 @@ export async function nextSteps(root, ctx, result) {
47
47
  const steps = [];
48
48
  const wrote = (path) => result.decisions.some((d) => d.output.path === path && WROTE.includes(d.action));
49
49
  if (ctx.config.modules.hooks && wrote("lefthook.yml")) {
50
- steps.push(ctx.stacks.some((s) => s.id === "node")
50
+ steps.push(nodeAtRoot(ctx.stacks)
51
51
  ? "install dependencies; this installs the git hooks"
52
52
  : `run \`npx --yes lefthook@${TOOL_VERSIONS.lefthook} install\` to enable the git hooks`);
53
53
  }
@@ -11,8 +11,8 @@ import { assertSupportedStandard } from "./check.js";
11
11
  import { buildContext } from "./context.js";
12
12
  import { guardUncommitted } from "./init.js";
13
13
  import { nextSteps, printResult, printWarnings } from "./report.js";
14
- export async function updateCommand(root, options, io) {
15
- const config = await loadConfig(root);
14
+ export async function updateCommand(root, options, io, change) {
15
+ const config = change?.config ?? (await loadConfig(root));
16
16
  assertSupportedStandard(config.standard);
17
17
  const lock = await readLock(root);
18
18
  if (!lock)
@@ -31,6 +31,7 @@ export async function updateCommand(root, options, io) {
31
31
  io.out("dry run: nothing written");
32
32
  return 0;
33
33
  }
34
+ await change?.beforeWrite();
34
35
  await applySync(root, result, lock, STANDARD_VERSION);
35
36
  const steps = await nextSteps(root, ctx, result);
36
37
  await printWarnings(root, ctx, io);
@@ -1,5 +1,7 @@
1
+ import { ACCESS_LEVELS } from "../gitlab/settings.js";
1
2
  import { STACK_IDS } from "./types.js";
2
3
  const flag = { type: "boolean" };
4
+ const ACCESS = Object.keys(ACCESS_LEVELS);
3
5
  export const configSchema = {
4
6
  type: "object",
5
7
  additionalProperties: false,
@@ -46,6 +48,16 @@ export const configSchema = {
46
48
  additionalProperties: false,
47
49
  properties: {
48
50
  default_branch: { type: "string", minLength: 1 },
51
+ workflows: {
52
+ type: "object",
53
+ additionalProperties: false,
54
+ properties: {
55
+ // "local", or the owner/name of a repository holding a copy of the reusable workflows
56
+ source: { type: "string", pattern: "^(local|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)$" },
57
+ // "exact", or a tag, branch or commit SHA
58
+ ref: { type: "string", pattern: "^[A-Za-z0-9_./-]+$" },
59
+ },
60
+ },
49
61
  description: { type: "string" },
50
62
  topics: { type: "array", uniqueItems: true, items: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,49}$" } },
51
63
  merge: {
@@ -78,7 +90,34 @@ export const configSchema = {
78
90
  gitlab: {
79
91
  type: "object",
80
92
  additionalProperties: false,
81
- properties: { default_branch: { type: "string", minLength: 1 } },
93
+ properties: {
94
+ default_branch: { type: "string", minLength: 1 },
95
+ description: { type: "string" },
96
+ topics: { type: "array", uniqueItems: true, items: { type: "string", minLength: 1 } },
97
+ merge: {
98
+ type: "object",
99
+ additionalProperties: false,
100
+ properties: {
101
+ method: { enum: ["merge", "rebase_merge", "ff"] },
102
+ squash: { enum: ["never", "always", "default_on", "default_off"] },
103
+ delete_source_branch: flag,
104
+ pipeline_must_succeed: flag,
105
+ discussions_must_be_resolved: flag,
106
+ },
107
+ },
108
+ protect: {
109
+ anyOf: [
110
+ { const: false },
111
+ {
112
+ type: "object",
113
+ additionalProperties: false,
114
+ properties: { push: { enum: ACCESS }, merge: { enum: ACCESS }, allow_force_push: flag },
115
+ },
116
+ ],
117
+ },
118
+ // five cron fields, read in UTC; false removes the schedule repokeeper made
119
+ renovate_schedule: { anyOf: [{ const: false }, { type: "string", pattern: "^\\S+( \\S+){4}$" }] },
120
+ },
82
121
  },
83
122
  },
84
123
  };
@@ -1,11 +1,11 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { parse } from "yaml";
4
- import { REUSABLE_REPO } from "./version.js";
5
4
  const WORKFLOWS = ".github/workflows";
6
5
  const calls = (job) => {
7
6
  const uses = job?.uses;
8
- return (typeof uses === "string" && (uses.startsWith(`${REUSABLE_REPO}/`) || uses.startsWith("./.github/workflows/stack-")));
7
+ // a stack workflow, wherever it is called from: repokeeper's repository, a copy of it, or this repository
8
+ return typeof uses === "string" && /(^\.|^[^/]+\/[^/]+)\/\.github\/workflows\/stack-[a-z]+\.yml(@|$)/.test(uses);
9
9
  };
10
10
  /** Workflows of the user's own that run a stack's test command, which repokeeper's ci job now runs as well. */
11
11
  export async function duplicateTestRuns(root, stacks) {
package/dist/errors.js CHANGED
@@ -18,7 +18,7 @@ export class UsageError extends RepokeeperError {
18
18
  super(message, 2);
19
19
  }
20
20
  }
21
- /** A GitHub API call failed; nothing on disk was touched. */
21
+ /** A GitHub or GitLab API call failed; nothing on disk was touched. */
22
22
  export class ApiError extends RepokeeperError {
23
23
  constructor(message) {
24
24
  super(message, 3);
@@ -0,0 +1,50 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { ApiError } from "../errors.js";
4
+ const execFileAsync = promisify(execFile);
5
+ /** `GITLAB_TOKEN`, or the token the GitLab CLI holds for the host. */
6
+ export async function resolveGitlabToken(host, env = process.env) {
7
+ if (env.GITLAB_TOKEN)
8
+ return env.GITLAB_TOKEN;
9
+ try {
10
+ const { stdout } = await execFileAsync("glab", ["config", "get", "token", "--host", host], { encoding: "utf8" });
11
+ if (stdout.trim())
12
+ return stdout.trim();
13
+ }
14
+ catch {
15
+ // fall through to the error below
16
+ }
17
+ throw new ApiError(`no GitLab token for ${host}: set GITLAB_TOKEN or sign in with \`glab auth login\``);
18
+ }
19
+ export function gitlabRestApi(token, host) {
20
+ const base = `https://${host}/api/v4`;
21
+ return {
22
+ async request(method, path, body) {
23
+ let response;
24
+ try {
25
+ response = await fetch(`${base}${path}`, {
26
+ method,
27
+ headers: {
28
+ accept: "application/json",
29
+ // read as a personal, project or group access token, and as the OAuth token of `glab`
30
+ authorization: `Bearer ${token}`,
31
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
32
+ },
33
+ body: body === undefined ? undefined : JSON.stringify(body),
34
+ });
35
+ }
36
+ catch (error) {
37
+ throw new ApiError(`GitLab API ${method} ${path} failed: ${error.message}`);
38
+ }
39
+ const text = await response.text();
40
+ let data = null;
41
+ try {
42
+ data = text ? JSON.parse(text) : null;
43
+ }
44
+ catch {
45
+ data = text;
46
+ }
47
+ return { status: response.status, data };
48
+ },
49
+ };
50
+ }