mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(scripts): run gh without terminal formatting
This commit is contained in:
parent
2ba9d6eabe
commit
f13a10c798
12 changed files with 321 additions and 23 deletions
|
|
@ -4,6 +4,14 @@ set -euo pipefail
|
|||
repo="openclaw/openclaw"
|
||||
months="12"
|
||||
include_global="0"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(git -C "$script_dir/../../../.." rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -z "$repo_root" ]; then
|
||||
repo_root="$(cd "$script_dir/../../../.." && pwd)"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$repo_root/scripts/lib/plain-gh.sh"
|
||||
|
||||
usage() {
|
||||
printf 'Usage: %s [--repo owner/repo] [--months N] [--global] <github-login> [login...]\n' "$0"
|
||||
|
|
@ -18,6 +26,10 @@ need() {
|
|||
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
|
||||
}
|
||||
|
||||
gh() {
|
||||
gh_plain "$@"
|
||||
}
|
||||
|
||||
date_utc_relative_months() {
|
||||
local count="$1"
|
||||
if date -u -v-"${count}"m +%Y-%m-%dT00:00:00Z >/dev/null 2>&1; then
|
||||
|
|
@ -131,7 +143,8 @@ done
|
|||
exit 2
|
||||
}
|
||||
|
||||
need gh
|
||||
OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh"
|
||||
export OPENCLAW_GH_BIN
|
||||
need jq
|
||||
|
||||
since_ts=$(date_utc_relative_months "$months")
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@
|
|||
* Usage: node secret-scanning.mjs <command> [options]
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs";
|
||||
|
||||
const REPO = "openclaw/openclaw";
|
||||
const REPO_URL = `https://github.com/${REPO}`;
|
||||
|
|
@ -29,7 +29,7 @@ function tmpFile(purpose) {
|
|||
}
|
||||
|
||||
function gh(args, { json = true, allowFailure = false } = {}) {
|
||||
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
||||
const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
|
||||
if (proc.status !== 0 && !allowFailure) {
|
||||
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs";
|
||||
|
||||
const runId = process.argv[2];
|
||||
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
|
||||
|
|
@ -15,8 +16,9 @@ if (!runId) {
|
|||
}
|
||||
|
||||
function gh(args) {
|
||||
return execFileSync("gh", args, {
|
||||
return execFileSync(resolvePlainGhBin(), args, {
|
||||
encoding: "utf8",
|
||||
env: plainGhEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
|
@ -32,14 +34,15 @@ function githubRestJson(pathSuffix) {
|
|||
"-lc",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'token="$(gh auth token)"',
|
||||
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
|
||||
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
|
||||
].join("\n"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
...plainGhEnv(),
|
||||
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
|
||||
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// Summarizes GitHub Actions run/job timings for CI analysis.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parsePositiveInt } from "./lib/numeric-options.mjs";
|
||||
import { execPlainGh } from "./lib/plain-gh.mjs";
|
||||
|
||||
const DEFAULT_GITHUB_REPOSITORY = "openclaw/openclaw";
|
||||
const RUN_JOBS_PAGE_SIZE = 20;
|
||||
|
|
@ -17,12 +18,17 @@ function parseJsonCommand(command, args, options = {}) {
|
|||
let lastError;
|
||||
for (let attempt = 0; attempt <= GH_JSON_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||
try {
|
||||
return JSON.parse(
|
||||
execFileSync(command, args, {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
const stdout =
|
||||
command === "gh"
|
||||
? execPlainGh(args, {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
})
|
||||
: execFileSync(command, args, {
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
return JSON.parse(stdout);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -216,8 +222,7 @@ export function selectLatestMainPushCiRun(runs, headSha = null) {
|
|||
}
|
||||
|
||||
function getLatestCiRunId() {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
const raw = execPlainGh(
|
||||
["run", "list", "--branch", "main", "--workflow", "CI", "--limit", "1", "--json", "databaseId"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
|
@ -240,8 +245,7 @@ function getRemoteMainSha() {
|
|||
|
||||
function getLatestMainPushCiRunId() {
|
||||
const headSha = getRemoteMainSha();
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
const raw = execPlainGh(
|
||||
[
|
||||
"run",
|
||||
"list",
|
||||
|
|
@ -264,8 +268,7 @@ function getLatestMainPushCiRunId() {
|
|||
}
|
||||
|
||||
function listRecentSuccessfulCiRuns(limit) {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
const raw = execPlainGh(
|
||||
[
|
||||
"run",
|
||||
"list",
|
||||
|
|
|
|||
84
scripts/lib/plain-gh.mjs
Normal file
84
scripts/lib/plain-gh.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function isExecutable(filePath) {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathEntries(env) {
|
||||
return String(env.PATH ?? "")
|
||||
.split(path.delimiter)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function plainGhEnv(env = process.env) {
|
||||
const next = { ...env };
|
||||
delete next.CLICOLOR;
|
||||
delete next.CLICOLOR_FORCE;
|
||||
delete next.COLORTERM;
|
||||
delete next.GH_FORCE_TTY;
|
||||
next.NO_COLOR = "1";
|
||||
next.FORCE_COLOR = "0";
|
||||
next.CLICOLOR = "0";
|
||||
next.CLICOLOR_FORCE = "0";
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resolvePlainGhBin(env = process.env) {
|
||||
if (env.OPENCLAW_GH_BIN) {
|
||||
if (isExecutable(env.OPENCLAW_GH_BIN)) {
|
||||
return env.OPENCLAW_GH_BIN;
|
||||
}
|
||||
throw new Error(`OPENCLAW_GH_BIN is not executable: ${env.OPENCLAW_GH_BIN}`);
|
||||
}
|
||||
|
||||
for (const candidate of ["/opt/homebrew/bin/gh", "/usr/local/bin/gh"]) {
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const homeBin = env.HOME ? path.join(env.HOME, "bin") : "";
|
||||
for (const entry of pathEntries(env)) {
|
||||
if (homeBin && entry === homeBin) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh");
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of pathEntries(env)) {
|
||||
const candidate = path.join(entry, process.platform === "win32" ? "gh.exe" : "gh");
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("missing required command: gh");
|
||||
}
|
||||
|
||||
export function execPlainGh(args, options = {}) {
|
||||
const env = plainGhEnv(options.env ?? process.env);
|
||||
const ghBin = resolvePlainGhBin(env);
|
||||
return execFileSync(ghBin, args, {
|
||||
...options,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
export function spawnPlainGh(args, options = {}) {
|
||||
const env = plainGhEnv(options.env ?? process.env);
|
||||
const ghBin = resolvePlainGhBin(env);
|
||||
return spawnSync(ghBin, args, {
|
||||
...options,
|
||||
env,
|
||||
});
|
||||
}
|
||||
70
scripts/lib/plain-gh.sh
Normal file
70
scripts/lib/plain-gh.sh
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
plain_gh_env() {
|
||||
env \
|
||||
-u CLICOLOR \
|
||||
-u CLICOLOR_FORCE \
|
||||
-u COLORTERM \
|
||||
-u GH_FORCE_TTY \
|
||||
NO_COLOR=1 \
|
||||
FORCE_COLOR=0 \
|
||||
CLICOLOR=0 \
|
||||
CLICOLOR_FORCE=0 \
|
||||
"$@"
|
||||
}
|
||||
|
||||
resolve_plain_gh_bin() {
|
||||
if [ -n "${OPENCLAW_GH_BIN:-}" ]; then
|
||||
if [ -x "$OPENCLAW_GH_BIN" ]; then
|
||||
printf '%s\n' "$OPENCLAW_GH_BIN"
|
||||
return 0
|
||||
fi
|
||||
printf 'OPENCLAW_GH_BIN is not executable: %s\n' "$OPENCLAW_GH_BIN" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local candidate
|
||||
for candidate in /opt/homebrew/bin/gh /usr/local/bin/gh; do
|
||||
if [ -x "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
if candidate=$(PATH="$(plain_gh_search_path)" type -P gh 2>/dev/null); then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
type -P gh 2>/dev/null
|
||||
}
|
||||
|
||||
plain_gh_search_path() {
|
||||
local path_value="${PATH:-}"
|
||||
local home_bin="${HOME:-}/bin"
|
||||
local item
|
||||
local output=""
|
||||
local first=true
|
||||
local path_parts=()
|
||||
|
||||
IFS=':' read -r -a path_parts <<<"$path_value"
|
||||
for item in "${path_parts[@]}"; do
|
||||
if [ -n "${HOME:-}" ] && [ "$item" = "$home_bin" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ "$first" = "true" ]; then
|
||||
output="$item"
|
||||
first=false
|
||||
else
|
||||
output="${output}:$item"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s\n' "$output"
|
||||
}
|
||||
|
||||
gh_plain() {
|
||||
local gh_bin
|
||||
gh_bin=$(resolve_plain_gh_bin) || return 1
|
||||
plain_gh_env "$gh_bin" "$@"
|
||||
}
|
||||
14
scripts/pr
14
scripts/pr
|
|
@ -21,6 +21,9 @@ if common_git_dir=$(git -C "$script_parent_dir" rev-parse --path-format=absolute
|
|||
fi
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$script_parent_dir/lib/plain-gh.sh"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage:
|
||||
|
|
@ -48,11 +51,16 @@ USAGE
|
|||
require_cmds() {
|
||||
local missing=()
|
||||
local cmd
|
||||
for cmd in git gh jq rg pnpm node; do
|
||||
for cmd in git jq rg pnpm node; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
missing+=("$cmd")
|
||||
fi
|
||||
done
|
||||
if ! OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)"; then
|
||||
missing+=("gh")
|
||||
else
|
||||
export OPENCLAW_GH_BIN
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "Missing required command(s): ${missing[*]}"
|
||||
|
|
@ -60,6 +68,10 @@ require_cmds() {
|
|||
fi
|
||||
}
|
||||
|
||||
gh() {
|
||||
gh_plain "$@"
|
||||
}
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$script_parent_dir/pr-lib/worktree.sh"
|
||||
# shellcheck disable=SC1091
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { execPlainGh } from "./lib/plain-gh.mjs";
|
||||
|
||||
export const SCHEDULED_HOSTED_WORKFLOWS = [
|
||||
"Blacksmith Testbox",
|
||||
|
|
@ -209,8 +209,7 @@ export function collectHostedGateEvidence({ sha, workflowRuns, changelogOnly = f
|
|||
}
|
||||
|
||||
function loadWorkflowRuns(repo, sha) {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
const raw = execPlainGh(
|
||||
["api", `repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`, "--paginate", "--slurp"],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ exit 64
|
|||
env: {
|
||||
...process.env,
|
||||
FAKE_GH_LOG: logPath,
|
||||
OPENCLAW_GH_BIN: ghPath,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
108
test/scripts/plain-gh.test.ts
Normal file
108
test/scripts/plain-gh.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Plain GitHub CLI helper tests cover wrapper-safe gh execution for maintainer scripts.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { plainGhEnv, resolvePlainGhBin } from "../../scripts/lib/plain-gh.mjs";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeFakeGh(): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "plain-gh-"));
|
||||
tempDirs.push(dir);
|
||||
const binDir = path.join(dir, "bin");
|
||||
mkdirSync(binDir);
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
writeFileSync(
|
||||
ghPath,
|
||||
`#!/usr/bin/env bash
|
||||
printf 'argv=%s\\n' "$*"
|
||||
printf 'NO_COLOR=%s\\n' "\${NO_COLOR-}"
|
||||
printf 'GH_FORCE_TTY=%s\\n' "\${GH_FORCE_TTY-}"
|
||||
printf 'FORCE_COLOR=%s\\n' "\${FORCE_COLOR-}"
|
||||
printf 'CLICOLOR=%s\\n' "\${CLICOLOR-}"
|
||||
printf 'CLICOLOR_FORCE=%s\\n' "\${CLICOLOR_FORCE-}"
|
||||
printf 'COLORTERM_SET=%s\\n' "\${COLORTERM+x}"
|
||||
`,
|
||||
);
|
||||
chmodSync(ghPath, 0o755);
|
||||
return ghPath;
|
||||
}
|
||||
|
||||
describe("plain gh helpers", () => {
|
||||
it("prefers OPENCLAW_GH_BIN over PATH shims", () => {
|
||||
const ghPath = makeFakeGh();
|
||||
|
||||
expect(
|
||||
resolvePlainGhBin({
|
||||
HOME: path.dirname(path.dirname(ghPath)),
|
||||
OPENCLAW_GH_BIN: ghPath,
|
||||
PATH: "",
|
||||
}),
|
||||
).toBe(ghPath);
|
||||
});
|
||||
|
||||
it("normalizes color environment for JSON-safe gh output", () => {
|
||||
expect(
|
||||
plainGhEnv({
|
||||
CLICOLOR: "1",
|
||||
CLICOLOR_FORCE: "1",
|
||||
COLORTERM: "truecolor",
|
||||
FORCE_COLOR: "3",
|
||||
}),
|
||||
).toMatchObject({
|
||||
NO_COLOR: "1",
|
||||
FORCE_COLOR: "0",
|
||||
CLICOLOR: "0",
|
||||
CLICOLOR_FORCE: "0",
|
||||
});
|
||||
expect(plainGhEnv({ COLORTERM: "truecolor" })).not.toHaveProperty("COLORTERM");
|
||||
expect(plainGhEnv({ GH_FORCE_TTY: "120" })).not.toHaveProperty("GH_FORCE_TTY");
|
||||
});
|
||||
|
||||
it("runs the shell helper with color disabled", () => {
|
||||
const ghPath = makeFakeGh();
|
||||
const outputPath = path.join(path.dirname(path.dirname(ghPath)), "output.txt");
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
"source scripts/lib/plain-gh.sh",
|
||||
`OPENCLAW_GH_BIN=${JSON.stringify(ghPath)}`,
|
||||
"export OPENCLAW_GH_BIN",
|
||||
`gh_plain api rate_limit > ${JSON.stringify(outputPath)}`,
|
||||
].join("\n");
|
||||
|
||||
const result = spawnSync("bash", ["-lc", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
CLICOLOR: "1",
|
||||
CLICOLOR_FORCE: "1",
|
||||
COLORTERM: "truecolor",
|
||||
FORCE_COLOR: "3",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readFileSync(outputPath, "utf8")).toContain("argv=api rate_limit");
|
||||
expect(readFileSync(outputPath, "utf8")).toContain("NO_COLOR=1");
|
||||
expect(readFileSync(outputPath, "utf8")).toContain("GH_FORCE_TTY=");
|
||||
expect(readFileSync(outputPath, "utf8")).toContain("FORCE_COLOR=0");
|
||||
expect(readFileSync(outputPath, "utf8")).toContain("CLICOLOR=0");
|
||||
expect(readFileSync(outputPath, "utf8")).toContain("CLICOLOR_FORCE=0");
|
||||
expect(readFileSync(outputPath, "utf8")).toContain("COLORTERM_SET=");
|
||||
});
|
||||
|
||||
it("keeps the shell resolver on external gh binaries", () => {
|
||||
const helper = readFileSync("scripts/lib/plain-gh.sh", "utf8");
|
||||
|
||||
expect(helper).toContain("type -P gh");
|
||||
expect(helper).not.toContain("command -v gh");
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,9 @@ describe("scripts/pr wrappers", () => {
|
|||
|
||||
expect(script).toContain("export NO_COLOR=1");
|
||||
expect(script).toContain("unset COLORTERM");
|
||||
expect(script).toContain('source "$script_parent_dir/lib/plain-gh.sh"');
|
||||
expect(script).toContain("OPENCLAW_GH_BIN=");
|
||||
expect(script).toContain("gh_plain");
|
||||
expect(script).toContain("scripts/pr review-init <PR>");
|
||||
expect(script).toContain("scripts/pr prepare-run <PR>");
|
||||
expect(script).toContain("scripts/pr merge-run <PR>");
|
||||
|
|
|
|||
|
|
@ -38,9 +38,10 @@ describe("secret scanning maintainer script", () => {
|
|||
const tempDir = createTempDir("openclaw-secret-scan-");
|
||||
const binDir = path.join(tempDir, "bin");
|
||||
const ghLog = path.join(tempDir, "gh.log");
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
fs.mkdirSync(binDir);
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, "gh"),
|
||||
ghPath,
|
||||
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "${ghLog}"\nprintf '{}\\n'\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
|
@ -58,6 +59,7 @@ describe("secret scanning maintainer script", () => {
|
|||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_GH_BIN: ghPath,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue