mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
fix(dispatch): let the answer to the agent's own question continue the task it blocked on
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1edb9ec02
commit
722303e4a2
8 files changed
+273
-2
No files matched your search
@@ -4106,6 +4106,19 @@ An absent, changed, pending, or agent-resolved response grants no such exception
|
||||
That bounded response remains a continuation after a workspace or AI-account wait,
|
||||
so final dispatch still verifies it. Finishing a dependency during preparation
|
||||
does not turn the admitted response into an attempt to check out the task.
|
||||
|
||||
A person's answer to the agent's own question also continues a task that the same
|
||||
agent blocked while it waited for that answer. The agent can report such a wait as
|
||||
a blocker, for example after a later comment from the person moved the question
|
||||
into history. Dispatch accepts the Blocked task only through durable linkage: the
|
||||
interaction was created by the assignee on this task and resolved by a person,
|
||||
and the task's current Blocked status is the applied status decision
|
||||
(`current_track_blocker_waiting` or `task_wide_blocker_bound`) of a run of the
|
||||
same agent, recorded after the question was asked and before it was answered,
|
||||
with no later move into Blocked. The continuation then checks the task out to
|
||||
`in_progress` and clears its blocker fields. A task blocked by a person, blocked
|
||||
before the question existed, reassigned, done or cancelled still cancels the
|
||||
answer's continuation as before.
|
||||
Task interruption notices ignore Retry receipts for a superseded run. A later
|
||||
successful conversation must not inherit the old failure banner. This read-only
|
||||
projection neither retires Retry authority nor acknowledges independent saved input.
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
completionContracts,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
nativeRunFinalizations,
|
||||
nativeRunResults,
|
||||
workAssessments,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { commitNativeStatusDecision } from "../services/native-runtime/status-decision-committer.js";
|
||||
import { NATIVE_STATUS_ARBITER_POLICY_VERSION } from "../services/native-runtime/status-arbiter.js";
|
||||
|
||||
const mockAdapterExecute = vi.hoisted(() => vi.fn(async () => ({
|
||||
exitCode: 0, signal: null, timedOut: false, errorMessage: null,
|
||||
summary: "Wrote the brief with the answer.", provider: "test", model: "test-model",
|
||||
})));
|
||||
|
||||
vi.mock("../adapters/index.ts", async () => {
|
||||
const actual = await vi.importActual<typeof import("../adapters/index.ts")>("../adapters/index.ts");
|
||||
return { ...actual, getServerAdapter: vi.fn(() => ({ supportsLocalAgentJwt: false, execute: mockAdapterExecute })) };
|
||||
});
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("an answer to the agent's own question continues the task it blocked on", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let heartbeat!: ReturnType<typeof heartbeatService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const userId = "blocked-question-user";
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-blocked-question-answer-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
await db.insert(authUsers).values({ id: userId, name: "Board", email: "board@example.test",
|
||||
createdAt: new Date(), updatedAt: new Date() });
|
||||
heartbeat = heartbeatService(db);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await heartbeat?.drainActiveRunExecutions();
|
||||
await db?.$client.end();
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Question fixture",
|
||||
issuePrefix: `Q${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false, defaultResponsibleUserId: userId });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId,
|
||||
status: "active", membershipRole: "owner" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Writer", role: "engineer", status: "active",
|
||||
adapterType: "codex_local", adapterConfig: {}, permissions: {},
|
||||
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Write the brief", status: "in_review",
|
||||
assigneeAgentId: agentId, responsibleUserId: userId });
|
||||
const askingRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: askingRunId, companyId, agentId, status: "succeeded",
|
||||
runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system",
|
||||
contextSnapshot: { issueId }, finishedAt: new Date() });
|
||||
const [question] = await db.insert(issueThreadInteractions).values({ companyId, issueId,
|
||||
createdByAgentId: agentId, sourceRunId: askingRunId, kind: "ask_user_questions", status: "pending",
|
||||
continuationPolicy: "wake_assignee", title: "Pick a codename",
|
||||
payload: { version: 1, questions: [{ id: "codename", prompt: "Which codename?", selectionMode: "single",
|
||||
options: [{ id: "alpha", label: "ALPHA" }, { id: "bravo", label: "BRAVO" }] }] },
|
||||
}).returning();
|
||||
return { companyId, agentId, issueId, question: question! };
|
||||
}
|
||||
|
||||
async function agentReportsBlocker(input: { companyId: string; agentId: string; issueId: string }) {
|
||||
const runId = randomUUID(), contractId = randomUUID(), resultId = randomUUID(), assessmentId = randomUUID();
|
||||
await db.insert(completionContracts).values({ id: contractId, companyId: input.companyId, issueId: input.issueId,
|
||||
revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "standard",
|
||||
completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal",
|
||||
contractJson: {}, canonicalSha256: contractId, createdByActorType: "system", createdByActorId: "test" });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId,
|
||||
status: "running", runtimeMode: "native", nativeIssueId: input.issueId, invocationSource: "automation",
|
||||
triggerDetail: "system", completionContractId: contractId, contextSnapshot: { issueId: input.issueId } });
|
||||
await db.insert(nativeRunResults).values({ id: resultId, companyId: input.companyId, issueId: input.issueId,
|
||||
runId, completionContractId: contractId, serverFingerprint: resultId, schemaStatus: "accepted",
|
||||
resultJson: {}, canonicalSha256: resultId });
|
||||
const [snapshot] = await db.select().from(issues).where(eq(issues.id, input.issueId));
|
||||
await db.insert(workAssessments).values({ id: assessmentId, companyId: input.companyId, issueId: input.issueId,
|
||||
runId, contractId, resultId, triggerKind: "native_result", triggerActorCompanyId: input.companyId,
|
||||
priorIssueStatus: snapshot!.status, priorStatusVersion: snapshot!.statusVersion,
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, assessmentJson: {}, inputDigest: assessmentId });
|
||||
await db.insert(nativeRunFinalizations).values({ runId, companyId: input.companyId, issueId: input.issueId,
|
||||
phase: "arbitrating", resultId, assessmentId });
|
||||
await commitNativeStatusDecision({
|
||||
db, companyId: input.companyId, issueId: input.issueId, runId, assessmentId,
|
||||
priorStatus: snapshot!.status, priorStatusVersion: Number(snapshot!.statusVersion),
|
||||
priorDecisionId: snapshot!.lastStatusDecisionId,
|
||||
decision: { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "blocked", toStatus: "blocked",
|
||||
reasonCode: "current_track_blocker_waiting",
|
||||
unblockDescriptor: { owner: "board", action: "Answer the pending codename question." },
|
||||
effects: [{ kind: "release_checkout" }] },
|
||||
});
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
|
||||
}
|
||||
|
||||
async function answer(question: typeof issueThreadInteractions.$inferSelect) {
|
||||
await db.update(issueThreadInteractions).set({ status: "answered", resolvedByUserId: userId, resolvedAt: new Date(),
|
||||
result: { version: 1, answers: [{ questionId: "codename", optionIds: ["bravo"] }] } })
|
||||
.where(eq(issueThreadInteractions.id, question.id));
|
||||
}
|
||||
|
||||
async function deliverAnswer(input: { agentId: string; issueId: string; questionId: string }) {
|
||||
const context = { issueId: input.issueId, mutation: "interaction", interactionId: input.questionId,
|
||||
interactionKind: "ask_user_questions", interactionStatus: "answered",
|
||||
interactionResolvedAt: new Date().toISOString(), wakeReason: "issue_commented" };
|
||||
const run = await heartbeat.wakeup(input.agentId, { source: "automation", triggerDetail: "system",
|
||||
reason: "issue_commented", payload: context, contextSnapshot: context,
|
||||
requestedByActorType: "user", requestedByActorId: userId });
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
return run ? (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]! : null;
|
||||
}
|
||||
|
||||
it("delivers the answer when the agent blocked the task to wait for it", async () => {
|
||||
mockAdapterExecute.mockClear();
|
||||
const { agentId, issueId, question, companyId } = await seed();
|
||||
await agentReportsBlocker({ companyId, agentId, issueId });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]!.status).toBe("blocked");
|
||||
await answer(question);
|
||||
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
|
||||
expect({ status: run?.status, errorCode: run?.errorCode ?? null, error: run?.error ?? null })
|
||||
.toEqual({ status: "succeeded", errorCode: null, error: null });
|
||||
expect(run?.startedAt).not.toBeNull();
|
||||
expect(run?.contextSnapshot).toMatchObject({ interactionId: question.id, interactionStatus: "answered" });
|
||||
expect(mockAdapterExecute).toHaveBeenCalled();
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(issue).toMatchObject({ blockedTransitionAt: null, unblockDescriptor: null });
|
||||
}, 60_000);
|
||||
|
||||
it("keeps cancelling the answer when a person blocked the task after the agent", async () => {
|
||||
mockAdapterExecute.mockClear();
|
||||
const { agentId, issueId, question, companyId } = await seed();
|
||||
await agentReportsBlocker({ companyId, agentId, issueId });
|
||||
await db.update(issues).set({ status: "in_review", blockedTransitionAt: null }).where(eq(issues.id, issueId));
|
||||
await db.update(issues).set({ status: "blocked", blockedTransitionAt: new Date(Date.now() + 1_000) })
|
||||
.where(eq(issues.id, issueId));
|
||||
await answer(question);
|
||||
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
|
||||
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_not_in_progress" });
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
}, 60_000);
|
||||
|
||||
it("keeps cancelling the answer when the agent blocked before asking", async () => {
|
||||
mockAdapterExecute.mockClear();
|
||||
const { agentId, issueId, question, companyId } = await seed();
|
||||
await agentReportsBlocker({ companyId, agentId, issueId });
|
||||
await db.update(issueThreadInteractions).set({ createdAt: new Date(Date.now() + 1_000) })
|
||||
.where(eq(issueThreadInteractions.id, question.id));
|
||||
await answer(question);
|
||||
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
|
||||
expect(run).toMatchObject({ status: "cancelled", errorCode: "issue_not_in_progress" });
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
}, 60_000);
|
||||
|
||||
it("keeps cancelling the answer once the task is done", async () => {
|
||||
mockAdapterExecute.mockClear();
|
||||
const { agentId, issueId, question, companyId } = await seed();
|
||||
await agentReportsBlocker({ companyId, agentId, issueId });
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
||||
await answer(question);
|
||||
const run = await deliverAnswer({ agentId, issueId, questionId: question.id });
|
||||
if (run) expect(run).toMatchObject({ status: "cancelled" });
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import { lockRunBudgetRecoveryOwnerInTransaction, recheckRunBudgetReservationInT
|
||||
import { isCompletedOnboardingHandoffWake } from "../../../services/chat-completion-delivery.js";
|
||||
import { isUnstartedTaskQuestionAnswer } from "../../../services/unstarted-task-question-answer.js";
|
||||
import { isVerifiedDependencyInteractionResponse } from "../../../services/queued-interaction-response.js";
|
||||
import { isAnswerToAgentBlockingQuestion } from "../../../services/blocked-question-answer.js";
|
||||
import { isHeartbeatWakeOnDemandEnabled } from "../../../services/heartbeat-policy.js";
|
||||
import { notifyChatRunMilestones } from "../../../services/chat-run-milestone-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../../../services/delivery-work-notifications.js";
|
||||
@@ -706,6 +707,9 @@ export function createPostgresRunDispatchAdapter(
|
||||
&& context.dependencyBlockedInteraction === true
|
||||
&& await isVerifiedDependencyInteractionResponse(dbOrTx, { companyId: input.companyId, issueId,
|
||||
agentId: input.agentId, context })),
|
||||
isAgentBlockingQuestionAnswer: Boolean(isResolvedInteractionContinuation && issue?.status === "blocked"
|
||||
&& await isAnswerToAgentBlockingQuestion(dbOrTx, { companyId: input.companyId, issueId,
|
||||
agentId: input.agentId, context })),
|
||||
isUnstartedTaskQuestionAnswer: Boolean(isResolvedInteractionContinuation && issue?.status === "todo" &&
|
||||
await isUnstartedTaskQuestionAnswer(dbOrTx, { companyId: input.companyId, issueId, interactionId: context.interactionId })),
|
||||
isConnectionContinuation: (isResolvedInteractionContinuation && context.interactionKind === "connection_intent")
|
||||
|
||||
@@ -267,6 +267,19 @@ describe("decideScheduledRetryGate", () => {
|
||||
});
|
||||
|
||||
describe("decideQueuedRunStaleness", () => {
|
||||
it("continues a task its agent blocked to wait for the answered question", () => {
|
||||
const answer = { ...baseStalenessFacts(), isResolvedInteractionContinuation: true,
|
||||
issueStatus: "blocked", isAgentBlockingQuestionAnswer: true };
|
||||
expect(decideQueuedRunStaleness(answer, NOW)).toEqual({ stale: false });
|
||||
expect(decideQueuedRunStaleness({ ...answer, isAgentBlockingQuestionAnswer: false }, NOW))
|
||||
.toMatchObject({ stale: true, errorCode: "issue_not_in_progress" });
|
||||
expect(decideQueuedRunStaleness({ ...answer, issueAssigneeAgentId: "other-agent" }, NOW))
|
||||
.toMatchObject({ stale: true, errorCode: "issue_assignee_changed" });
|
||||
for (const issueStatus of ["backlog", "done", "cancelled"]) {
|
||||
expect(decideQueuedRunStaleness({ ...answer, issueStatus }, NOW)).toMatchObject({ stale: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a verified response bounded to a blocked task and its current assignee", () => {
|
||||
const response = { ...baseStalenessFacts(), isResolvedInteractionContinuation: true,
|
||||
issueStatus: "blocked", isDependencyBlockedInteractionResponse: true };
|
||||
|
||||
@@ -149,6 +149,7 @@ export type QueuedRunFacts = {
|
||||
isUnstartedTaskQuestionAnswer?: boolean;
|
||||
/** The stored human response was verified again for bounded dependency interaction. */
|
||||
isDependencyBlockedInteractionResponse?: boolean;
|
||||
isAgentBlockingQuestionAnswer?: boolean;
|
||||
/** A connection resolution or tool refresh can resume an agent waiting in review. */
|
||||
isConnectionContinuation?: boolean;
|
||||
isInteractionWake: boolean;
|
||||
@@ -516,7 +517,8 @@ export function decideQueuedRunStaleness(
|
||||
if (facts.isResolvedInteractionContinuation || facts.isConnectionContinuation) {
|
||||
// A todo task that has never started has no parked execution to protect.
|
||||
const startsUnstartedTask = facts.issueStatus === "todo" && facts.isUnstartedTaskQuestionAnswer === true;
|
||||
const answersBlockedTask = facts.issueStatus === "blocked" && facts.isDependencyBlockedInteractionResponse === true;
|
||||
const answersBlockedTask = facts.issueStatus === "blocked" &&
|
||||
(facts.isDependencyBlockedInteractionResponse === true || facts.isAgentBlockingQuestionAnswer === true);
|
||||
const earlyStatus = decideIssueStatus({
|
||||
status: facts.issueStatus,
|
||||
requiresInProgress: facts.issueStatus !== "in_review" && !startsUnstartedTask && !answersBlockedTask,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { and, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, issueThreadInteractions, issues, statusDecisions, type Db } from "@paperclipai/db";
|
||||
import { queuedInteractionId } from "./queued-interaction-response.js";
|
||||
|
||||
const AGENT_REPORTED_BLOCKER_REASON_CODES = ["current_track_blocker_waiting", "task_wide_blocker_bound"];
|
||||
|
||||
export async function isAnswerToAgentBlockingQuestion(db: Pick<Db, "select">, input: {
|
||||
companyId: string; issueId: string; agentId: string; context: unknown;
|
||||
}): Promise<boolean> {
|
||||
const interactionId = queuedInteractionId(input.context);
|
||||
if (!interactionId) return false;
|
||||
const interactionStatus = (input.context as Record<string, unknown>).interactionStatus;
|
||||
const rows = await db
|
||||
.select({ id: issueThreadInteractions.id })
|
||||
.from(issueThreadInteractions)
|
||||
.innerJoin(issues, and(
|
||||
eq(issues.id, issueThreadInteractions.issueId),
|
||||
eq(issues.companyId, issueThreadInteractions.companyId),
|
||||
))
|
||||
.innerJoin(statusDecisions, and(
|
||||
eq(statusDecisions.id, issues.lastStatusDecisionId),
|
||||
eq(statusDecisions.companyId, issues.companyId),
|
||||
eq(statusDecisions.issueId, issues.id),
|
||||
))
|
||||
.innerJoin(heartbeatRuns, and(
|
||||
eq(heartbeatRuns.id, statusDecisions.runId),
|
||||
eq(heartbeatRuns.companyId, statusDecisions.companyId),
|
||||
))
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, interactionId),
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
eq(issueThreadInteractions.issueId, input.issueId),
|
||||
eq(issueThreadInteractions.createdByAgentId, input.agentId),
|
||||
eq(issueThreadInteractions.status, String(interactionStatus)),
|
||||
isNotNull(issueThreadInteractions.resolvedByUserId),
|
||||
isNotNull(issueThreadInteractions.resolvedAt),
|
||||
eq(issues.status, "blocked"),
|
||||
eq(issues.assigneeAgentId, input.agentId),
|
||||
isNotNull(issues.blockedTransitionAt),
|
||||
eq(statusDecisions.applicationState, "applied"),
|
||||
eq(statusDecisions.toStatus, "blocked"),
|
||||
inArray(statusDecisions.reasonCode, AGENT_REPORTED_BLOCKER_REASON_CODES),
|
||||
gte(statusDecisions.createdAt, issueThreadInteractions.createdAt),
|
||||
sql`${statusDecisions.createdAt} <= ${issueThreadInteractions.resolvedAt}`,
|
||||
sql`${statusDecisions.appliedAt} >= ${issues.blockedTransitionAt}`,
|
||||
eq(heartbeatRuns.agentId, input.agentId),
|
||||
))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { isProcessRunAdmissionClosed } from "./heartbeat/run-admission.js";
|
||||
import type { StopInvocationOptions } from "./heartbeat/run-control.js";
|
||||
import { isContextCompactionRun } from "./context-compaction.js";
|
||||
import { isUnstartedTaskQuestionAnswer } from "./unstarted-task-question-answer.js";
|
||||
import { isAnswerToAgentBlockingQuestion } from "./blocked-question-answer.js";
|
||||
import { isVerifiedDependencyInteractionResponse } from "./queued-interaction-response.js";
|
||||
import { registerDeferredReceiptWork } from "./heartbeat/deferred-receipt-work.js";
|
||||
import { registerProviderLimitResumeWork, type PrepareChatRetry } from "./native-runtime/provider-limit-resume.js";
|
||||
@@ -2304,10 +2305,13 @@ export function heartbeatService(
|
||||
await timePreparation("issue_checkout", async () => {
|
||||
const startsUnstartedTask = issueStatus === "todo" &&
|
||||
await isUnstartedTaskQuestionAnswer(db, { companyId: agent.companyId, issueId, interactionId: context.interactionId });
|
||||
const answersAgentBlockingQuestion = issueStatus === "blocked" &&
|
||||
await isAnswerToAgentBlockingQuestion(db, { companyId: agent.companyId, issueId, agentId: agent.id, context });
|
||||
await issuesSvc.checkout(
|
||||
issueId,
|
||||
agent.id,
|
||||
[...resolvedInteractionCheckoutExpectedStatuses(), ...(startsUnstartedTask ? ["todo"] : [])],
|
||||
[...resolvedInteractionCheckoutExpectedStatuses(), ...(startsUnstartedTask ? ["todo"] : []),
|
||||
...(answersAgentBlockingQuestion ? ["blocked"] : [])],
|
||||
run.id,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12651,6 +12651,11 @@ export function issueService(db: Db) {
|
||||
status: "in_progress",
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
...(expectedStatuses.includes("blocked") ? {
|
||||
unblockDescriptor: sql`case when ${issues.status} = 'blocked' then null else ${issues.unblockDescriptor} end`,
|
||||
blockedTransitionAt: sql`case when ${issues.status} = 'blocked' then null else ${issues.blockedTransitionAt} end`,
|
||||
blockedOwnerNotifiedAt: sql`case when ${issues.status} = 'blocked' then null else ${issues.blockedOwnerNotifiedAt} end`,
|
||||
} : {}),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
|
||||
Reference in new issue
Block a user