fix: persist container auth, restore tasks filter, refine PR status UI (#883)

This commit is contained in:
Carlos Florêncio
2026-05-12 18:06:14 +01:00
committed by GitHub
parent 45a46f9899
commit 211e8615fb
14 changed files with 396 additions and 19 deletions
+5 -2
View File
@@ -77,8 +77,10 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* && \
PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise
# Replace the node base image's default user so we own uid 1000
RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -m kandev
# Replace the node base image's default user so we own uid 1000.
# Home is placed under /data so agent CLI auth state (gh, claude, codex, auggie,
# copilot, amp, ...) lives on the PV and survives pod restarts and image upgrades.
RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -d /data/home -M kandev
# Create app directory structure matching what the CLI expects:
# /app/apps/backend/bin/kandev
@@ -113,6 +115,7 @@ VOLUME ["/data"]
ENV KANDEV_NO_BROWSER=1 \
KANDEV_HOME_DIR=/data \
KANDEV_DOCKER_ENABLED=false \
HOME=/data/home \
NPM_CONFIG_PREFIX=/data/.npm-global \
PATH=/data/.npm-global/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
HOSTNAME=0.0.0.0 \
+8 -2
View File
@@ -8,6 +8,7 @@ import {
import { fetchUserSettings } from "@/lib/api";
import { StateHydrator } from "@/components/state-hydrator";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow";
import { TasksPageClient } from "./tasks-page-client";
import type {
Workflow,
@@ -50,8 +51,13 @@ async function fetchWorkspaceData(
);
const workflows = workflowsResponse.workflows;
const activeWorkflowId =
workflows.find((w) => w.id === savedWorkflowId)?.id ?? workflows[0]?.id ?? null;
// Preserve "All Workflows" (null/empty saved filter) instead of falling back
// to the first workflow — resolveDesiredWorkflowId handles this consistently
// with the kanban page and the root tasks page.
const activeWorkflowId = resolveDesiredWorkflowId({
settingsWorkflowId: savedWorkflowId,
workspaceWorkflows: workflows,
});
return {
workflows,
+22 -7
View File
@@ -172,6 +172,25 @@ function DescriptionSection({ body }: { body: string }) {
);
}
// GitHub logins are case-insensitive; normalize before comparing.
function shouldHideApproveButton(
taskPR: TaskPR,
feedback: PRFeedback | null,
currentUser: string | null,
): boolean {
const liveState = feedback?.pr.state ?? taskPR.state;
if (liveState !== "open") return true;
const normalizedUser = currentUser?.trim().toLowerCase();
if (!normalizedUser) return false;
const prAuthor = feedback?.pr.author_login ?? taskPR.author_login;
if (prAuthor?.trim().toLowerCase() === normalizedUser) return true;
return (
feedback?.reviews?.some(
(r) => r.state === "APPROVED" && r.author?.trim().toLowerCase() === normalizedUser,
) ?? false
);
}
function ApproveButton({
taskPR,
feedback,
@@ -183,14 +202,9 @@ function ApproveButton({
}) {
const { toast } = useToast();
const [submitting, setSubmitting] = useState(false);
const currentUser = useAppStore((s) => s.githubStatus.status?.username ?? null);
const liveState = feedback?.pr.state ?? taskPR.state;
if (liveState !== "open") return null;
const alreadyApproved = feedback?.reviews?.some(
(r) => r.state === "APPROVED" && r.author === feedback.pr.author_login,
);
if (alreadyApproved) return null;
if (shouldHideApproveButton(taskPR, feedback, currentUser)) return null;
const handleApprove = async () => {
setSubmitting(true);
@@ -211,6 +225,7 @@ function ApproveButton({
return (
<Button
data-testid="pr-approve-button"
size="sm"
className="cursor-pointer gap-1.5 border-0 bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500"
onClick={handleApprove}
@@ -12,7 +12,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-
import { useTaskPR } from "@/hooks/domains/github/use-task-pr";
import { usePRFeedbackBackgroundSync } from "@/hooks/domains/github/use-pr-ci-popover";
import { PRCIPopover } from "@/components/github/pr-ci-popover";
import { isPRReadyToMerge } from "@/components/github/pr-task-icon";
import { isPRAwaitingReview, isPRReadyToMerge } from "@/components/github/pr-task-icon";
import type { TaskPR } from "@/lib/types/github";
const HOVER_OPEN_DELAY_MS = 150;
@@ -27,8 +27,12 @@ function chipStatus(pr: TaskPR): ChipStatus {
// Pending checks / pending review must beat checks_state === "success" so a
// PR with all checks green but reviewers still outstanding renders as
// in-progress, not passed. Without this order, the chip flips to green the
// moment CI finishes and ignores the human gate.
// moment CI finishes and ignores the human gate. isPRAwaitingReview also
// covers approved PRs where branch protection requires more reviewers.
if (pr.checks_state === "pending" || pr.review_state === "pending") return "in_progress";
// Mirror getPRStatusColor priority: ready-to-merge beats awaiting-review so
// the chip and icon never disagree on a (theoretical) clean+approved+pending PR.
if (isPRAwaitingReview(pr) && !isPRReadyToMerge(pr)) return "in_progress";
if (pr.checks_state === "success") return "passed";
return "neutral";
}
@@ -161,6 +161,20 @@ describe("getPRStatusColor", () => {
expect(getPRStatusColor(pr)).toBe("text-green-500");
});
it("returns sky-400 for approved PR that still has pending reviewers (1 of N required)", () => {
// GitHub's review_state="approved" only means at least one reviewer approved;
// when branch protection requires more reviews, mergeable_state="blocked" and
// pending_review_count > 0. The icon must not imply the PR is fully approved.
const pr = makePR({
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
pending_review_count: 1,
});
expect(getPRStatusColor(pr)).toBe("text-sky-400");
});
it("returns plain green when mergeable_state is empty (backfilled row)", () => {
const pr = makePR({
state: "open",
@@ -259,7 +273,8 @@ describe("isPRAwaitingReview", () => {
).toBe(false);
});
it("is false for an approved PR with extra reviewers still pending", () => {
it("is true for an approved PR with extra reviewers still pending", () => {
// One reviewer approved but branch protection requires more — still awaiting.
expect(
isPRAwaitingReview(
makePR({
@@ -269,6 +284,19 @@ describe("isPRAwaitingReview", () => {
pending_review_count: 1,
}),
),
).toBe(true);
});
it("is false for an approved PR with no pending reviewers", () => {
expect(
isPRAwaitingReview(
makePR({
state: "open",
review_state: "approved",
checks_state: "success",
pending_review_count: 0,
}),
),
).toBe(false);
});
});
+10 -5
View File
@@ -32,11 +32,14 @@ export function isPRReadyToMerge(pr: TaskPR): boolean {
}
// CI passed but the PR is still waiting on human review (reviewers requested
// or pending review state). Distinct from yellow "CI running".
// or pending review state). Distinct from yellow "CI running". An approved
// PR with extra reviewers still pending also counts — GitHub's
// review_state="approved" only means at least one reviewer approved, not
// that branch protection's required count is met.
export function isPRAwaitingReview(pr: TaskPR): boolean {
if (pr.state !== "open") return false;
if (pr.checks_state !== "success") return false;
if (pr.review_state === "approved") return false;
if (pr.review_state === "approved") return pr.pending_review_count > 0;
return pr.review_state === "pending" || pr.pending_review_count > 0;
}
@@ -49,12 +52,14 @@ export function getPRStatusColor(pr: TaskPR): string {
if (isPRReadyToMerge(pr)) {
return "text-emerald-400";
}
if (pr.review_state === "approved" && pr.checks_state === "success") {
return "text-green-500";
}
// Check awaiting-review before the plain-green fallback so an approved PR
// with pending reviewers (1 of N required) doesn't read as fully approved.
if (isPRAwaitingReview(pr)) {
return "text-sky-400";
}
if (pr.review_state === "approved" && pr.checks_state === "success") {
return "text-green-500";
}
if (pr.checks_state === "pending" || pr.review_state === "pending") {
return "text-yellow-500";
}
@@ -25,6 +25,7 @@ import { useIsMobile } from "@/hooks/use-mobile";
import {
aggregatePRStatusColor,
getPRStatusColor,
isPRAwaitingReview,
isPRReadyToMerge,
} from "@/components/github/pr-task-icon";
import { prTaskKey } from "@/components/github/pr-detail-panel";
@@ -50,6 +51,11 @@ function PRStatusIcon({ pr }: { pr: TaskPR }) {
if (isPRReadyToMerge(pr)) {
return <IconCheck className="h-3 w-3 text-emerald-400" />;
}
// Check awaiting-review before the plain approved check so an approved PR
// with pending reviewers (1 of N required) doesn't read as fully approved.
if (isPRAwaitingReview(pr)) {
return <IconClock className="h-3 w-3 text-sky-400" />;
}
if (pr.checks_state === "success" && pr.review_state === "approved") {
return <IconCheck className="h-3 w-3 text-green-500" />;
}
+6
View File
@@ -428,6 +428,12 @@ export class SessionPage {
return this.page.getByTestId("pr-detail-panel");
}
/** "Approve PR" button inside the PR detail panel header. Hidden when the
* current GitHub user authored the PR (self-approval is rejected upstream). */
prApproveButton(): Locator {
return this.page.getByTestId("pr-approve-button");
}
// --- CI hover popover accessors (kept here so the spec stays declarative) ---
/** The single-PR hover popover content (visible after hovering the topbar button). */
@@ -0,0 +1,149 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
type SeedOpts = {
apiClient: ApiClient;
workspaceId: string;
agentProfileId: string;
repositoryId: string;
title: string;
currentUser: string;
};
async function seedApproveTest(opts: SeedOpts) {
const { apiClient, workspaceId, agentProfileId, repositoryId, title, currentUser } = opts;
const workflow = await apiClient.createWorkflow(workspaceId, `${title} Workflow`);
const inboxStep = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
const workingStep = await apiClient.createWorkflowStep(workflow.id, "Working", 1);
const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 2);
await apiClient.updateWorkflowStep(workingStep.id, {
prompt: 'e2e:message("done")\n{{task_prompt}}',
events: {
on_enter: [{ type: "auto_start_agent" }],
on_turn_complete: [{ type: "move_to_step", config: { step_id: doneStep.id } }],
},
});
await apiClient.saveUserSettings({
workspace_id: workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser(currentUser);
const task = await apiClient.createTask(workspaceId, title, {
workflow_id: workflow.id,
workflow_step_id: inboxStep.id,
agent_profile_id: agentProfileId,
repository_ids: [repositoryId],
});
return { workflow, workingStep, doneStep, task };
}
async function openTaskAndPRPanel(testPage: Page, doneStepId: string, title: string) {
const kanban = new KanbanPage(testPage);
await kanban.taskCardInColumn(title, doneStepId).click();
await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 });
await session.prTopbarButton().click();
await expect(session.prDetailPanel()).toBeVisible({ timeout: 10_000 });
return session;
}
test.describe("PR Approve button visibility", () => {
/**
* Regression: the Approve PR button must be hidden when the current GitHub
* user is the author of the PR — GitHub's API rejects self-approval, so the
* button would only ever produce a failed request.
*/
test("is hidden when current user authored the PR", async ({ testPage, apiClient, seedData }) => {
test.setTimeout(120_000);
const { workflow, workingStep, doneStep, task } = await seedApproveTest({
apiClient,
workspaceId: seedData.workspaceId,
agentProfileId: seedData.agentProfileId,
repositoryId: seedData.repositoryId,
title: "Self-Authored PR",
currentUser: "test-user",
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await apiClient.moveTask(task.id, workflow.id, workingStep.id);
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "testorg",
repo: "testrepo",
pr_number: 201,
pr_url: "https://github.com/testorg/testrepo/pull/201",
pr_title: "My own PR",
head_branch: "feat/self",
base_branch: "main",
author_login: "test-user",
state: "open",
});
await expect(kanban.taskCardInColumn("Self-Authored PR", doneStep.id)).toBeVisible({
timeout: 45_000,
});
const session = await openTaskAndPRPanel(testPage, doneStep.id, "Self-Authored PR");
await expect(session.prApproveButton()).toBeHidden();
});
/**
* Control: when the PR is authored by someone else, the Approve button
* must render so the user can submit an approval.
*/
test("is visible when PR is authored by another user", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { workflow, workingStep, doneStep, task } = await seedApproveTest({
apiClient,
workspaceId: seedData.workspaceId,
agentProfileId: seedData.agentProfileId,
repositoryId: seedData.repositoryId,
title: "Other-Authored PR",
currentUser: "test-user",
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await apiClient.moveTask(task.id, workflow.id, workingStep.id);
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "testorg",
repo: "testrepo",
pr_number: 202,
pr_url: "https://github.com/testorg/testrepo/pull/202",
pr_title: "Someone else's PR",
head_branch: "feat/other",
base_branch: "main",
author_login: "someone-else",
state: "open",
});
await expect(kanban.taskCardInColumn("Other-Authored PR", doneStep.id)).toBeVisible({
timeout: 45_000,
});
const session = await openTaskAndPRPanel(testPage, doneStep.id, "Other-Authored PR");
await expect(session.prApproveButton()).toBeVisible({ timeout: 10_000 });
});
});
@@ -214,4 +214,59 @@ test.describe("PR status badge", () => {
// Plain-green approved state, not the ready-to-merge emerald.
await expect(icon).not.toHaveClass(/text-emerald-400/);
});
/**
* GitHub's review_state="approved" only means at least one reviewer approved.
* When branch protection requires more approvals, the PR is still blocked
* and pending_review_count > 0. The badge must read as "awaiting review"
* (sky-400) rather than fully approved (green-500) or ready-to-merge
* (emerald-400).
*/
test("renders awaiting-review when approved with pending reviewers", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { workflow, workingStep, doneStep, task } = await seedBadgeTest(
apiClient,
seedData.workspaceId,
seedData.agentProfileId,
seedData.repositoryId,
"Awaiting Review Task",
);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await apiClient.moveTask(task.id, workflow.id, workingStep.id);
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "testorg",
repo: "testrepo",
pr_number: 104,
pr_url: "https://github.com/testorg/testrepo/pull/104",
pr_title: "1 of 2 approvals",
head_branch: "feat/partial-approval",
base_branch: "main",
author_login: "test-user",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
pending_review_count: 1,
});
await expect(kanban.taskCardInColumn("Awaiting Review Task", doneStep.id)).toBeVisible({
timeout: 45_000,
});
const icon = testPage.getByTestId(`pr-task-icon-${task.id}`);
await expect(icon).toBeVisible({ timeout: 15_000 });
await expect(icon).toHaveAttribute("data-pr-ready-to-merge", "false");
await expect(icon).toHaveClass(/text-sky-400/);
await expect(icon).not.toHaveClass(/text-emerald-400/);
await expect(icon).not.toHaveClass(/text-green-500/);
});
});
@@ -145,6 +145,40 @@ test.describe("Task list display filters", () => {
await expect(testPage.getByText("Beta task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
});
test("'All Workflows' is preserved when navigating with workspace query param", async ({
testPage,
apiClient,
seedData,
}) => {
const workflowB = await apiClient.createWorkflow(seedData.workspaceId, "Workflow B", "simple");
const stepsB = (await apiClient.listWorkflowSteps(workflowB.id)).steps;
const startB = findStartStep(stepsB);
await apiClient.createTask(seedData.workspaceId, "Alpha task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.createTask(seedData.workspaceId, "Beta task", {
workflow_id: workflowB.id,
workflow_step_id: startB.id,
});
// Persist "All Workflows" (empty workflow_filter_id) before navigating.
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: "",
});
// Navigate to /tasks with the workspace query param — the SSR resolver
// must not silently fall back to the first workflow when "All Workflows"
// is the saved selection.
await testPage.goto(`/tasks?workspace=${seedData.workspaceId}`);
await testPage.getByTestId("display-button").waitFor();
await expect(testPage.getByText("Alpha task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
await expect(testPage.getByText("Beta task")).toBeVisible({ timeout: TASK_VISIBLE_TIMEOUT });
});
test("repository filter narrows the list to the selected repository", async ({
testPage,
apiClient,
+4
View File
@@ -2,6 +2,10 @@
set -e
if [ "$(id -u)" = '0' ]; then
# HOME for the kandev user lives on the PV so agent CLI auth state
# (gh, claude, codex, auggie, copilot, amp, ...) survives pod restarts
# and image upgrades. Make sure it exists before dropping privileges.
mkdir -p /data/home
chown -R kandev:kandev /data
exec gosu kandev "$@"
fi
+39
View File
@@ -48,6 +48,30 @@ docker run -v /path/on/host:/data ghcr.io/kdlbs/kandev:latest
Without a volume, data is lost when the container is removed.
### What lives on the volume
| Path | Contents |
|---|---|
| `/data/data/` | SQLite database (`kandev.db`, `-wal`, `-shm`) |
| `/data/worktrees/`, `/data/tasks/`, `/data/repos/`, `/data/sessions/`, `/data/lsp-servers/` | Per-session state |
| `/data/.npm-global/` | Agent CLIs installed via `npm install -g` (`NPM_CONFIG_PREFIX`) |
| `/data/home/` | `$HOME` for the in-container `kandev` user — `gh` CLI and agent CLI auth state |
### Persistent agent and `gh` CLI auth
The image sets `HOME=/data/home`, so every CLI that writes its auth under `$HOME` lands on the volume and survives container restarts and image upgrades:
- `gh` CLI — `~/.config/gh/hosts.yml`
- Claude Code — `~/.claude/.credentials.json`, `~/.claude.json`
- Codex — `~/.codex/auth.json`, `~/.codex/config.toml`
- Auggie — `~/.augment/session.json`
- GitHub Copilot — `~/.copilot/...`
- OpenCode, Amp — `~/.config/<tool>/...`
A one-time `docker exec -it kandev gh auth login` (or `claude login`, `codex login`, etc.) is enough; you do not need to redo it after `docker pull` and recreating the container.
> The GitHub PAT configured in **Settings → Integrations → GitHub** is stored as a secret in the database and has always persisted. The `HOME=/data/home` setup covers the separate `gh auth login` flow that the backend falls back to when no `GITHUB_TOKEN` secret is set.
## Configuration
Configuration is done via `KANDEV_`-prefixed environment variables:
@@ -228,6 +252,21 @@ docker run -p 38429:38429 \
> **Note:** Mounting the Docker socket gives the container full access to the host's Docker daemon. Only do this in trusted environments.
## Upgrading
```bash
docker pull ghcr.io/kdlbs/kandev:latest
docker compose up -d # or: docker stop kandev && docker rm kandev && docker run ...
```
The volume at `/data` carries over the database, worktrees, npm globals, and `$HOME` for agent CLIs, so there is no manual migration step.
> **Upgrading across the `HOME=/data/home` change:** if you previously ran `docker exec` to `gh auth login` or log in to agent CLIs on a pre-`HOME=/data/home` image, that state lived in the ephemeral `/home/kandev` inside the container and is not carried over. Log in once on the new container and it will persist for all subsequent upgrades. If you want to preserve the old state, copy it onto the volume before recreating the container:
>
> ```bash
> docker exec kandev sh -c 'cp -a /home/kandev/. /data/home/ 2>/dev/null || true'
> ```
## Health Check
The backend exposes a `/health` endpoint:
+23
View File
@@ -90,6 +90,21 @@ kubectl exec -it deployment/kandev -- npm install -g @anthropic-ai/claude-code
After installing, log in with the agent's own auth (e.g. `claude login`), then click **Rescan** on the agents page.
### Persistent agent and `gh` CLI auth
The image sets `HOME=/data/home` for the `kandev` user, so every CLI that writes its auth state under `$HOME` lands on the PV and survives pod restarts and image upgrades. This includes:
- `gh` CLI — `~/.config/gh/hosts.yml`
- Claude Code — `~/.claude/.credentials.json`, `~/.claude.json`
- Codex — `~/.codex/auth.json`, `~/.codex/config.toml`
- Auggie — `~/.augment/session.json`
- GitHub Copilot — `~/.copilot/...`
- OpenCode, Amp — `~/.config/<tool>/...`
So a one-time `kubectl exec -it deployment/kandev -- gh auth login` (or `claude login`, `codex login`, etc.) is enough; you do not need to redo it after `kubectl set image` or a `helm upgrade`.
> The GitHub PAT configured in **Settings → Integrations → GitHub** is stored as a secret in the SQLite DB (or your external Postgres) and has always persisted. The `HOME=/data/home` setup covers the separate `gh auth login` flow that the backend falls back to when no `GITHUB_TOKEN` secret is set.
## Configuration
Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper). Set these in `k8s/configmap.yaml` or as environment variables in the deployment.
@@ -162,6 +177,8 @@ The PVC at `/data` stores:
- **SQLite database** (`/data/data/kandev.db`, `/data/data/kandev.db-wal`, `/data/data/kandev.db-shm`)
- **Git worktrees** (`/data/worktrees/`), **tasks** (`/data/tasks/`), **repos** (`/data/repos/`), **sessions** (`/data/sessions/`), and **LSP servers** (`/data/lsp-servers/`)
- **User home** (`/data/home/`) — `$HOME` for the in-pod `kandev` user; holds `gh` CLI auth and agent CLI auth state (see [Persistent agent and `gh` CLI auth](#persistent-agent-and-gh-cli-auth) above)
- **npm globals** (`/data/.npm-global/`) — agent CLIs installed via `npm install -g`
The PVC uses `ReadWriteOnce` access mode. If your cluster requires a specific StorageClass, add it to `k8s/pvc.yaml`:
@@ -211,6 +228,12 @@ kubectl edit deployment kandev
SQLite migrations run automatically on startup — no manual migration step needed.
> **Upgrading across the `HOME=/data/home` change:** if you used `kubectl exec` to `gh auth login` or log in to agent CLIs on a pre-`HOME=/data/home` image, that state lived in the ephemeral `/home/kandev` and is not carried over. Log in once on the new pod and it will persist for all subsequent upgrades. If you want to keep the old state, copy it onto the PV before upgrading:
>
> ```bash
> kubectl exec deployment/kandev -- sh -c 'cp -a /home/kandev/. /data/home/ 2>/dev/null || true'
> ```
## Troubleshooting
```bash