diff --git a/.github/labeler.yml b/.github/labeler.yml index fa39c467f..658018c89 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -4,11 +4,6 @@ ci: - ".github/workflows/**" - ".github/**" -task: - - changed-files: - - any-glob-to-any-file: - - ".github/workflows/**" - platform: - changed-files: - any-glob-to-any-file: diff --git a/.github/scripts/pr-priority-triage.js b/.github/scripts/pr-priority-triage.js index b6e7f2de9..e75ef40f2 100644 --- a/.github/scripts/pr-priority-triage.js +++ b/.github/scripts/pr-priority-triage.js @@ -1,6 +1,9 @@ +import { POLICY } from "./label-policy-check.js" + export const TRIAGE_MARKER = "" export const PRIORITY_LABELS = ["P0", "P1", "P2", "P3"] +export const TYPE_LABELS = POLICY.types const LOW_RISK_GLOBS = [ "docs/**", @@ -11,6 +14,8 @@ const LOW_RISK_GLOBS = [ ] const USER_PATH_GLOBS = ["packages/app/src/**", "packages/desktop-electron/src/**"] +const RELEASE_BUMP_GLOBS = ["packages/desktop-electron/package.json", "bun.lock"] +const RELEASE_BUMP_REQUIRED_PATH = "packages/desktop-electron/package.json" function escapeRegex(value) { return value.replace(/[.+^${}()|[\]\\]/g, "\\$&") @@ -114,3 +119,38 @@ export function planPriorityLabels(paths, labels = []) { removeLabels: existingPriorities.filter((label) => label !== desiredPriority), } } + +export function classifyPullRequestType(paths) { + const normalized = paths.map((path) => path.replace(/\\/g, "/")) + + if ( + normalized.length > 0 && + normalized.includes(RELEASE_BUMP_REQUIRED_PATH) && + normalized.every((path) => matchesAny(path, RELEASE_BUMP_GLOBS)) + ) { + return "task" + } + + return undefined +} + +/** + * @param {string[]} paths + * @param {string[]} labels + */ +export function planPullRequestLabels(paths, labels = []) { + const priorityPlan = planPriorityLabels(paths, labels) + const labelSet = new Set(labels) + const existingTypes = TYPE_LABELS.filter((label) => labelSet.has(label)) + const inferredType = existingTypes.length === 0 ? classifyPullRequestType(paths) : undefined + const addLabels = [...priorityPlan.addLabels] + + if (inferredType && !labelSet.has(inferredType)) { + addLabels.push(inferredType) + } + + return { + ...priorityPlan, + addLabels, + } +} diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index d4e0eae1f..cc7d4a035 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -2,7 +2,7 @@ name: pr-triage on: pull_request_target: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, labeled, unlabeled] branches: [dev] concurrency: @@ -41,7 +41,7 @@ jobs: const policy = await import( pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, ".github/scripts/label-policy-check.js")).href, ) - const { buildPriorityReview, planPriorityLabels, TRIAGE_MARKER } = triage + const { buildPriorityReview, planPullRequestLabels, TRIAGE_MARKER } = triage const pull_number = Number(process.env.PR_NUMBER) const owner = context.repo.owner const repo = context.repo.repo @@ -60,7 +60,7 @@ jobs: issue_number: pull_number, per_page: 100, }) - const labelPlan = planPriorityLabels(paths, currentLabels.map((label) => label.name)) + const labelPlan = planPullRequestLabels(paths, currentLabels.map((label) => label.name)) if (labelPlan.addLabels.length > 0) { await github.rest.issues.addLabels({ diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c972ff44e..8a1990ce8 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,5 +1,6 @@ import { useSpring } from "@opencode-ai/ui/motion-spring" import { isWorkInFlightStatus } from "@opencode-ai/ui/util/session-status" +import { useNavigate, useParams } from "@solidjs/router" import { createEffect, on, Component, For, Show, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" @@ -38,7 +39,8 @@ import { pickAttachments } from "./prompt-input/pick-attachments" import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" import { promptLength } from "./prompt-input/history" import type { PromptStore } from "./prompt-input/store-types" -import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" +import type { FollowupDraft } from "./prompt-input/followup-draft" +import { createPromptSubmit } from "./prompt-input/submit" import { PromptPopover } from "./prompt-input/slash-popover" import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" @@ -392,6 +394,8 @@ export const PromptInput: Component = (props) => { if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) return permission.isAutoAccepting(id, sdk.directory) }) + const navigate = useNavigate() + const routeParams = useParams() const { abort, handleSubmit } = createPromptSubmit({ sessionID: activeSessionID, @@ -419,6 +423,8 @@ export const PromptInput: Component = (props) => { onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, + navigate, + routeParams: () => routeParams, }) createEffect(() => { diff --git a/packages/app/src/components/prompt-input/draft-isolation.integration.test.ts b/packages/app/src/components/prompt-input/draft-isolation.integration.test.ts index 2eb081fd2..7151f91e4 100644 --- a/packages/app/src/components/prompt-input/draft-isolation.integration.test.ts +++ b/packages/app/src/components/prompt-input/draft-isolation.integration.test.ts @@ -15,7 +15,7 @@ import { createPinnedDraftOwner } from "./pinned-draft" import { buildRequestParts } from "./build-request-parts" import { captureCommentMentions } from "./mention-metadata" -let detectSubmitOwnership: typeof import("./submit").detectSubmitOwnership +let detectSubmitOwnership: any beforeAll(async () => { // submit.ts pulls in router/sdk/etc. at module scope; mock bare minimum. diff --git a/packages/app/src/components/prompt-input/followup-draft.ts b/packages/app/src/components/prompt-input/followup-draft.ts new file mode 100644 index 000000000..deb87bb42 --- /dev/null +++ b/packages/app/src/components/prompt-input/followup-draft.ts @@ -0,0 +1,16 @@ +import type { ContextItem, Prompt } from "@/context/prompt" + +export type FollowupDraft = { + sessionID: string + sessionDirectory: string + prompt: Prompt + context: (ContextItem & { key: string })[] + agent: string + model: { providerID: string; modelID: string } + locale?: string + variant?: string +} + +export function followupCommandText(draft: FollowupDraft) { + return draft.prompt.map((part) => ("content" in part ? part.content : "")).join("") +} diff --git a/packages/app/src/components/prompt-input/submit-ownership.test.ts b/packages/app/src/components/prompt-input/submit-ownership.test.ts index d4026537e..2360140c7 100644 --- a/packages/app/src/components/prompt-input/submit-ownership.test.ts +++ b/packages/app/src/components/prompt-input/submit-ownership.test.ts @@ -2,10 +2,8 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { createPortableDraftOwner } from "./portable-draft" import { createPinnedDraftOwner } from "./pinned-draft" -// Submit module pulls in router/sdk/etc.; mock the bare minimum so a pure-function -// import does not blow up on solid-js server-side guards. -let detectSubmitOwnership: typeof import("./submit").detectSubmitOwnership -type SubmitOwnership = import("./submit").SubmitOwnership +let detectSubmitOwnership: any +type SubmitOwnership = any beforeAll(async () => { mock.module("@solidjs/router", () => ({ diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index dd0ecfb7a..b3ce2995f 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,8 +1,15 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import type { Prompt } from "@/context/prompt" +import type { + createPromptSubmit as createPromptSubmitType, + sendFollowupDraft as sendFollowupDraftType, +} from "./submit" -let createPromptSubmit: typeof import("./submit").createPromptSubmit -let sendFollowupDraft: typeof import("./submit").sendFollowupDraft +type PromptSubmitInput = Parameters[0] +type PromptSubmit = ReturnType + +let createPromptSubmit: (input: PromptSubmitInput) => PromptSubmit +let sendFollowupDraft: typeof sendFollowupDraftType const createdClients: string[] = [] const createdSessions: string[] = [] @@ -166,7 +173,7 @@ beforeAll(async () => { directory: "/repo/main", client: rootClient, url: "http://localhost:4096", - createClient(opts: any) { + createClient(opts: { directory: string }) { return clientFor(opts.directory) }, } @@ -281,6 +288,8 @@ describe("prompt submit worktree selection", () => { params = { id: "session-route" } const aborts: string[] = [] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -313,6 +322,8 @@ describe("prompt submit worktree selection", () => { const aborts: string[] = [] const submits: string[] = [] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -349,6 +360,8 @@ describe("prompt submit worktree selection", () => { commandDefinitions.push({ name: "summarize" }) promptValue = [{ type: "text", content: "/summarize this", start: 0, end: 15 }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-existing", isNewSession: () => false, info: () => ({ id: "session-existing" }), @@ -378,6 +391,8 @@ describe("prompt submit worktree selection", () => { commandsReady = false promptValue = [{ type: "text", content: "/bin/ls", start: 0, end: 7 }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-existing", isNewSession: () => false, info: () => ({ id: "session-existing" }), @@ -407,6 +422,8 @@ describe("prompt submit worktree selection", () => { const aborts: string[] = [] const submits: string[] = [] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -440,6 +457,8 @@ describe("prompt submit worktree selection", () => { params = { id: "session-visible" } promptValue = [{ type: "text", content: "", start: 0, end: 0 }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -465,6 +484,8 @@ describe("prompt submit worktree selection", () => { test("reads the latest worktree accessor value per submit", async () => { const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -502,6 +523,8 @@ describe("prompt submit worktree selection", () => { test("applies auto-accept to newly created sessions", async () => { const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -532,6 +555,8 @@ describe("prompt submit worktree selection", () => { variant = "high" const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-1" }), imageAttachments: () => [], commentCount: () => 0, @@ -565,6 +590,8 @@ describe("prompt submit worktree selection", () => { params = { id: "session-route" } const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, sessionID: () => "session-visible", isNewSession: () => false, info: () => ({ id: "session-visible" }), @@ -592,6 +619,8 @@ describe("prompt submit worktree selection", () => { test("seeds new sessions before optimistic prompts are added", async () => { const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -624,6 +653,8 @@ describe("prompt submit worktree selection", () => { promptValue = [{ type: "text", content: "run tests", start: 0, end: 9 }] promptAsyncFailure = new Error("send failed") const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -653,6 +684,8 @@ describe("prompt submit worktree selection", () => { currentIntl = "pt-BR" const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -680,6 +713,8 @@ describe("prompt submit worktree selection", () => { const queued: Array> = [] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -709,6 +744,8 @@ describe("prompt submit worktree selection", () => { promptValue = [{ type: "text", content: "/summarize this", start: 0, end: 15 }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -770,6 +807,8 @@ describe("prompt submit worktree selection", () => { } const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => undefined, imageAttachments: () => [], commentCount: () => 0, @@ -807,6 +846,8 @@ describe("Path D — marked TextPart routes through session.command", () => { }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -840,6 +881,8 @@ describe("Path D — marked TextPart routes through session.command", () => { ] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -874,6 +917,8 @@ describe("Path D — marked TextPart routes through session.command", () => { }] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -910,6 +955,8 @@ describe("Legacy fallback boundary (no marked TextPart)", () => { ] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, @@ -941,6 +988,8 @@ describe("Legacy fallback boundary (no marked TextPart)", () => { ] const submit = createPromptSubmit({ + navigate: (path) => navigateImpl(path), + routeParams: () => params, info: () => ({ id: "session-existing" }), imageAttachments: () => [], commentCount: () => 0, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 23041d327..c472c16ba 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -2,7 +2,6 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { Binary } from "@opencode-ai/util/binary" -import { useNavigate, useParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" import { useGlobalSync } from "@/context/global-sync" @@ -10,7 +9,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" -import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" +import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { emitRendererDiagnostic, sessionAbortDiagnosticEvent } from "@/context/renderer-diagnostics" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" @@ -27,6 +26,7 @@ import { type PromptRouteScope, promptScopeForSession } from "@/pages/session/pr import { type PortableDraftOwner, usePortableDraft } from "./portable-draft" import { type PinnedDraftOwner, usePinnedDraft } from "./pinned-draft" import type { ResolvedMention } from "./mention-metadata" +import { followupCommandText, type FollowupDraft } from "./followup-draft" /** * Submit ownership identifies which draft owner a given submit attempt operates on. @@ -76,17 +76,6 @@ type PendingPrompt = { const pending = new Map() type AbortSource = Extract -export type FollowupDraft = { - sessionID: string - sessionDirectory: string - prompt: Prompt - context: (ContextItem & { key: string })[] - agent: string - model: { providerID: string; modelID: string } - locale?: string - variant?: string -} - type FollowupSendInput = { client: ReturnType["client"] globalSync: ReturnType @@ -97,12 +86,6 @@ type FollowupSendInput = { before?: () => Promise | boolean } -const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") - -export function followupCommandText(draft: FollowupDraft) { - return draftText(draft.prompt) -} - const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") export async function sendFollowupDraft(input: FollowupSendInput) { @@ -295,6 +278,8 @@ type PromptSubmitInput = { onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void + navigate?: (path: string) => void + routeParams?: Accessor<{ dir?: string; id?: string }> } type CommentItem = { @@ -308,7 +293,7 @@ type CommentItem = { } export function createPromptSubmit(input: PromptSubmitInput) { - const navigate = useNavigate() + const navigate = input.navigate ?? (() => undefined) const sdk = useSDK() const sync = useSync() const globalSync = useGlobalSync() @@ -317,10 +302,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { const prompt = usePrompt() const layout = useLayout() const language = useLanguage() - const params = useParams() + const params: Accessor<{ dir?: string; id?: string }> = input.routeParams ?? (() => ({})) const portable = usePortableDraft() const pinned = usePinnedDraft() - const sessionID = input.sessionID ?? (() => params.id) + const sessionID = input.sessionID ?? (() => params().id) const isNewSession = input.isNewSession ?? (() => !sessionID()) const actionReady = input.actionReady ?? (() => true) const abortReady = input.abortReady ?? actionReady @@ -469,13 +454,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { // observe an EMPTY new-session store and a "session route" verdict, which // breaks ownership detection (Bug 1) and the comment-restore path. const submittedContext = prompt.context.items().slice() - const submittedIsHomepage = !params.id + const routeParams = params() + const submittedIsHomepage = !routeParams.id const projectDirectory = sdk.directory // Capture the source scope before any await so navigate() cannot change params.id under us const sourcePromptScope: PromptRouteScope = { - dir: params.dir ?? base64Encode(projectDirectory), - id: params.id, + dir: routeParams.dir ?? base64Encode(projectDirectory), + id: routeParams.id, } // Capture submit ownership BEFORE any await. Reading pinned.current() and @@ -586,7 +572,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const promptScope = promptScopeForSession({ - routeDir: params.dir, + routeDir: params().dir, routeDirectory: projectDirectory, targetDirectory: sessionDirectory, sessionID: session.id, diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index aa7a01d9f..e49943400 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -12,7 +12,7 @@ import { SessionQuestionDock } from "@/pages/session/composer/session-question-d import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" -import type { FollowupDraft } from "@/components/prompt-input/submit" +import type { FollowupDraft } from "@/components/prompt-input/followup-draft" export function SessionComposerRegion(props: { state: SessionComposerState diff --git a/packages/app/src/pages/session/session-action-readiness.test.ts b/packages/app/src/pages/session/session-action-readiness.test.ts index 2ed4fc99a..bc34e0ea6 100644 --- a/packages/app/src/pages/session/session-action-readiness.test.ts +++ b/packages/app/src/pages/session/session-action-readiness.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, mock, test } from "bun:test" +import { describe, expect, test } from "bun:test" import { canSendFollowupDraft, canSubmitPrompt, @@ -7,9 +7,9 @@ import { currentSessionSubmitReady, sessionStatusKnown, } from "./session-action-readiness" -import type { followupCommandText as FollowupCommandText, FollowupDraft } from "@/components/prompt-input/submit" +import { followupCommandText } from "@/components/prompt-input/followup-draft" -let followupCommandText: typeof FollowupCommandText +type FollowupDraft = any const slashDraft = { text: "/release now" } const normalDraft = { text: "continue" } @@ -25,16 +25,6 @@ const queuedDraft = (input: { prompt: FollowupDraft["prompt"] }): FollowupDraft }) describe("session action readiness", () => { - beforeAll(async () => { - mock.module("@solidjs/router", () => ({ - useNavigate: () => () => undefined, - useParams: () => ({}), - })) - - const mod = await import("@/components/prompt-input/submit") - followupCommandText = mod.followupCommandText - }) - test("direct slash submit waits for command hydration", () => { expect(canSubmitPrompt({ mode: "normal", text: "/release", submitReady: true, commandsReady: false })).toBe(false) expect(canSubmitPrompt({ mode: "normal", text: " /release", submitReady: true, commandsReady: false })).toBe(true) diff --git a/packages/app/src/pages/session/use-session-followups.test.ts b/packages/app/src/pages/session/use-session-followups.test.ts index fd29c5d15..b7aa8d7c4 100644 --- a/packages/app/src/pages/session/use-session-followups.test.ts +++ b/packages/app/src/pages/session/use-session-followups.test.ts @@ -2,7 +2,7 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { createRoot, createSignal } from "solid-js" import { createStore } from "solid-js/store" -import type { FollowupDraft } from "@/components/prompt-input/submit" +import { normalize, readPersistedAsync, readPersistedSync } from "@/utils/persist-read" import type { canSendFollowupItem as CanSendFollowupItem, createSessionFollowups as CreateSessionFollowups, @@ -25,6 +25,33 @@ let followupDraftMatchesScope: typeof FollowupDraftMatchesScope const sendFollowupCalls: unknown[] = [] let sendFollowupDraftImpl: (input: unknown) => Promise +type FollowupDraft = any + +function workspaceStorage(dir: string) { + const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") + let sum = 0 + for (let index = 0; index < dir.length; index++) { + sum = (sum + dir.charCodeAt(index) * (index + 1)) >>> 0 + } + return `pawwork.workspace.${head}.${sum.toString(36)}.dat` +} + +const PersistMock = { + global: (key: string, legacy?: string[]) => ({ storage: "pawwork.global.dat", key, legacy }), + workspace: (dir: string, key: string, legacy?: string[]) => ({ + storage: workspaceStorage(dir), + key: `workspace:${key}`, + legacy, + }), + session: (dir: string, session: string, key: string, legacy?: string[]) => ({ + storage: workspaceStorage(dir), + key: `session:${session}:${key}`, + legacy, + }), + scoped: (dir: string, session: string | undefined, key: string, legacy?: string[]) => + session ? PersistMock.session(dir, session, key, legacy) : PersistMock.workspace(dir, key, legacy), +} + const draft = (input: Pick): FollowupDraft => ({ sessionID: "ses_1", sessionDirectory: "/repo", @@ -47,8 +74,12 @@ beforeAll(async () => { usePlatform: () => ({ platform: "web" }), })) mock.module("@/utils/persist", () => ({ - Persist: { - global: (key: string, legacy?: string[]) => ({ key, legacy }), + Persist: PersistMock, + PersistTesting: { + normalize, + readPersistedAsync, + readPersistedSync, + workspaceStorage, }, persisted: (_target: unknown, store: unknown) => store, })) @@ -57,12 +88,6 @@ beforeAll(async () => { ascending: (prefix: string) => `${prefix}_queued`, }, })) - mock.module("@/components/prompt-input/submit", () => ({ - followupCommandText: (item: FollowupDraft) => - item.prompt.map((part) => ("content" in part ? part.content : "")).join(""), - sendFollowupDraft: (input: unknown) => sendFollowupDraftImpl(input), - })) - const mod = await import("./use-session-followups") followupPreviewText = mod.followupPreviewText shouldAutoSendFollowup = mod.shouldAutoSendFollowup @@ -299,6 +324,7 @@ describe("session followups", () => { fail: () => undefined, resumeScroll: () => undefined, attachmentLabel: () => "Attachment", + sendFollowup: ((input: unknown) => sendFollowupDraftImpl(input)) as never, }) return null }, @@ -375,6 +401,7 @@ describe("session followups", () => { fail: () => undefined, resumeScroll: () => undefined, attachmentLabel: () => "Attachment", + sendFollowup: ((input: unknown) => sendFollowupDraftImpl(input)) as never, }) return null }, diff --git a/packages/app/src/pages/session/use-session-followups.ts b/packages/app/src/pages/session/use-session-followups.ts index e3eddeb1c..cb4e3cfcd 100644 --- a/packages/app/src/pages/session/use-session-followups.ts +++ b/packages/app/src/pages/session/use-session-followups.ts @@ -1,8 +1,8 @@ import { createEffect, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useMutation } from "@tanstack/solid-query" -import type { FollowupDraft } from "@/components/prompt-input/submit" -import { followupCommandText, sendFollowupDraft } from "@/components/prompt-input/submit" +import { followupCommandText, type FollowupDraft } from "@/components/prompt-input/followup-draft" +import { sendFollowupDraft } from "@/components/prompt-input/submit" import type { useGlobalSync } from "@/context/global-sync" import type { useSDK } from "@/context/sdk" import type { useSettings } from "@/context/settings" @@ -104,6 +104,7 @@ export function createSessionFollowups(input: { fail: (err: unknown) => void resumeScroll: () => void attachmentLabel: () => string + sendFollowup?: typeof sendFollowupDraft }) { const [followup, setFollowup] = persisted( Persist.global("session-followup.v2", ["followup.v2"]), @@ -145,6 +146,7 @@ export function createSessionFollowups(input: { } const [pendingFollowups, setPendingFollowups] = createSignal>({}) + const sendFollowupDraftForInput = input.sendFollowup ?? sendFollowupDraft const markFollowupPending = (key: string, id: string) => { setPendingFollowups((current) => ({ ...current, [key]: id })) } @@ -169,7 +171,7 @@ export function createSessionFollowups(input: { const directory = input.directory() const draft = followupDraftForDirectory(item, directory) - const ok = await sendFollowupDraft({ + const ok = await sendFollowupDraftForInput({ client: input.client(), sync: input.sync, globalSync: input.globalSync, diff --git a/packages/desktop-electron/scripts/repair-electron-install.mjs b/packages/desktop-electron/scripts/repair-electron-install.mjs index 3284d73c0..7cb52148d 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.mjs +++ b/packages/desktop-electron/scripts/repair-electron-install.mjs @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process" -import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs" +import { createHash } from "node:crypto" +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs" import { createRequire } from "node:module" import { tmpdir } from "node:os" import { join } from "node:path" @@ -29,17 +30,18 @@ export function isElectronInstallComplete(electronDir, platform = process.platfo if (!existsSync(binaryPath)) return false if (platform === "darwin" || platform === "mas") { - return existsSync( - join( - electronDir, - "dist", - "Electron.app", - "Contents", - "Frameworks", - "Electron Framework.framework", - "Electron Framework", - ), + const frameworkDir = join( + electronDir, + "dist", + "Electron.app", + "Contents", + "Frameworks", + "Electron Framework.framework", ) + return [ + join(frameworkDir, "Electron Framework"), + join(frameworkDir, "Versions", "A", "Electron Framework"), + ].some((candidate) => existsSync(candidate)) } return true @@ -57,6 +59,61 @@ function resetElectronInstall(electronDir) { rmSync(join(electronDir, "dist"), { recursive: true, force: true }) } +function electronArtifactName({ version, platform, arch }) { + return `electron-v${version}-${platform}-${arch}.zip` +} + +function electronArtifactUrl({ version, platform, arch }) { + return `https://github.com/electron/electron/releases/download/v${version}/${electronArtifactName({ + version, + platform, + arch, + })}` +} + +function downloadFile(url, destination) { + execFileSync("curl", ["-fL", "--retry", "3", "--retry-delay", "2", "-o", destination, url], { stdio: "inherit" }) +} + +function verifyElectronZipChecksum(electronDir, zipPath, fileName) { + const checksums = JSON.parse(readFileSync(join(electronDir, "checksums.json"), "utf8")) + const expected = checksums[fileName] + if (typeof expected !== "string") { + throw new Error(`Missing Electron checksum for artifact: ${fileName}`) + } + + const actual = createHash("sha256").update(readFileSync(zipPath)).digest("hex") + if (actual !== expected) { + throw new Error(`Electron checksum mismatch for ${fileName}: expected ${expected}, got ${actual}`) + } +} + +function extractElectronZip(electronDir, zipPath) { + execFileSync("unzip", ["-q", zipPath, "-d", join(electronDir, "dist")], { stdio: "inherit" }) +} + +export function downloadElectronArtifact({ + electronDir, + platform, + arch, + download = downloadFile, + extractZip = extractElectronZip, +}) { + const { version } = JSON.parse(readFileSync(join(electronDir, "package.json"), "utf8")) + const fileName = electronArtifactName({ version, platform, arch }) + const scratchDir = mkdtempSync(join(tmpdir(), "pawwork-electron-artifact-")) + const zipPath = join(scratchDir, fileName) + + try { + download(electronArtifactUrl({ version, platform, arch }), zipPath) + verifyElectronZipChecksum(electronDir, zipPath, fileName) + resetElectronInstall(electronDir) + extractZip(electronDir, zipPath) + } finally { + rmSync(scratchDir, { recursive: true, force: true }) + } +} + function findZipFile(root) { for (const entry of readdirSync(root)) { const entryPath = join(root, entry) @@ -79,9 +136,16 @@ export function extractElectronZipFromCache(cacheRoot, electronDir) { return true } -export function electronInstallEnv({ cacheRoot, forceNoCache = false } = {}) { +export function electronInstallEnv({ + cacheRoot, + forceNoCache = false, + platform = process.platform, + arch = process.arch, +} = {}) { const env = { ...process.env } delete env.ELECTRON_SKIP_BINARY_DOWNLOAD + env.npm_config_platform = platform + env.npm_config_arch = arch if (forceNoCache) { env.force_no_cache = "true" @@ -99,6 +163,7 @@ export function repairElectronInstallAt( { installScript = join(electronDir, "install.js"), platform = process.platform, + arch = process.arch, runInstall, createCacheRoot = () => mkdtempSync(join(tmpdir(), "pawwork-electron-cache-")), extractFromCache = extractElectronZipFromCache, @@ -109,7 +174,7 @@ export function repairElectronInstallAt( ((script, options = {}) => { execFileSync(process.execPath, [script], { stdio: "inherit", - env: electronInstallEnv(options), + env: electronInstallEnv({ ...options, platform, arch }), }) }) @@ -135,6 +200,10 @@ export function repairElectronInstallAt( } } + if (!writeElectronPathFileIfInstallComplete(electronDir, platform)) { + downloadElectronArtifact({ electronDir, platform, arch }) + } + if (!writeElectronPathFileIfInstallComplete(electronDir, platform)) { throw new Error(`Electron install is still incomplete after repair: ${electronDir}`) } diff --git a/packages/desktop-electron/scripts/repair-electron-install.test.ts b/packages/desktop-electron/scripts/repair-electron-install.test.ts index dbb54e2c5..f36adb0a6 100644 --- a/packages/desktop-electron/scripts/repair-electron-install.test.ts +++ b/packages/desktop-electron/scripts/repair-electron-install.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test" +import { createHash } from "node:crypto" import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { + downloadElectronArtifact, electronInstallEnv, isElectronInstallComplete, platformPathForElectron, @@ -32,6 +34,13 @@ describe("repair Electron install", () => { } }) + test("pins install platform and arch to the repair target", () => { + const env = electronInstallEnv({ platform: "darwin", arch: "arm64" }) + + expect(env.npm_config_platform).toBe("darwin") + expect(env.npm_config_arch).toBe("arm64") + }) + test("does not treat a macOS install as complete when the framework is missing", () => { const electronDir = mkdtempSync(join(tmpdir(), "pawwork-electron-install-")) const platformPath = platformPathForElectron("darwin") @@ -60,6 +69,44 @@ describe("repair Electron install", () => { expect(readFileSync(join(electronDir, "path.txt"), "utf8")).toBe(platformPath) }) + test("accepts the versioned macOS Electron framework binary path", () => { + const electronDir = mkdtempSync(join(tmpdir(), "pawwork-electron-install-")) + const platformPath = platformPathForElectron("darwin") + mkdirSync(join(electronDir, "dist", "Electron.app", "Contents", "MacOS"), { recursive: true }) + mkdirSync( + join( + electronDir, + "dist", + "Electron.app", + "Contents", + "Frameworks", + "Electron Framework.framework", + "Versions", + "A", + ), + { recursive: true }, + ) + writeFileSync(join(electronDir, "dist", platformPath), "") + writeFileSync( + join( + electronDir, + "dist", + "Electron.app", + "Contents", + "Frameworks", + "Electron Framework.framework", + "Versions", + "A", + "Electron Framework", + ), + "", + ) + + expect(isElectronInstallComplete(electronDir, "darwin")).toBe(true) + expect(writeElectronPathFileIfInstallComplete(electronDir, "darwin")).toBe(true) + expect(readFileSync(join(electronDir, "path.txt"), "utf8")).toBe(platformPath) + }) + test("clears an incomplete Electron dist before reinstalling", () => { const electronDir = mkdtempSync(join(tmpdir(), "pawwork-electron-install-")) const platformPath = platformPathForElectron("darwin") @@ -69,6 +116,7 @@ describe("repair Electron install", () => { repairElectronInstallAt(electronDir, { platform: "darwin", + arch: "arm64", runInstall() { expect(existsSync(join(electronDir, "dist"))).toBe(false) mkdirSync(join(electronDir, "dist", "Electron.app", "Contents", "MacOS"), { recursive: true }) @@ -203,6 +251,51 @@ describe("repair Electron install", () => { expect(isElectronInstallComplete(electronDir, "darwin")).toBe(true) }) + test("can repair from a directly downloaded Electron artifact", async () => { + const electronDir = mkdtempSync(join(tmpdir(), "pawwork-electron-install-")) + const zipContents = "electron zip fixture" + const platformPath = platformPathForElectron("darwin") + + mkdirSync(join(electronDir, "dist", "stale"), { recursive: true }) + writeFileSync(join(electronDir, "package.json"), JSON.stringify({ version: "40.8.0" })) + const checksum = createHash("sha256").update(zipContents).digest("hex") + writeFileSync( + join(electronDir, "checksums.json"), + JSON.stringify({ "electron-v40.8.0-darwin-arm64.zip": checksum }), + ) + + downloadElectronArtifact({ + electronDir, + platform: "darwin", + arch: "arm64", + download(_url, destination) { + writeFileSync(destination, zipContents) + }, + extractZip(targetElectronDir) { + mkdirSync(join(targetElectronDir, "dist", "Electron.app", "Contents", "MacOS"), { recursive: true }) + mkdirSync(join(targetElectronDir, "dist", "Electron.app", "Contents", "Frameworks", "Electron Framework.framework"), { + recursive: true, + }) + writeFileSync(join(targetElectronDir, "dist", platformPath), "") + writeFileSync( + join( + targetElectronDir, + "dist", + "Electron.app", + "Contents", + "Frameworks", + "Electron Framework.framework", + "Electron Framework", + ), + "", + ) + }, + }) + + expect(existsSync(join(electronDir, "dist", "stale"))).toBe(false) + expect(isElectronInstallComplete(electronDir, "darwin")).toBe(true) + }) + test("extracts from the isolated cache when the no-cache reinstall stays incomplete", () => { const electronDir = mkdtempSync(join(tmpdir(), "pawwork-electron-install-")) const platformPath = platformPathForElectron("darwin") diff --git a/packages/opencode/test/github/pr-triage-workflow.test.ts b/packages/opencode/test/github/pr-triage-workflow.test.ts index 5e338c741..f320aa796 100644 --- a/packages/opencode/test/github/pr-triage-workflow.test.ts +++ b/packages/opencode/test/github/pr-triage-workflow.test.ts @@ -4,6 +4,7 @@ import { validateLabelPolicy } from "../../../../.github/scripts/label-policy-ch import { buildPriorityReview, classifyPriority, + planPullRequestLabels, planPriorityLabels, TRIAGE_MARKER, } from "../../../../.github/scripts/pr-priority-triage.js" @@ -38,7 +39,9 @@ describe("pr triage workflow", () => { test("defines labeler routing for repo areas and workflow policy labels", () => { const config = readWorkflow(labelerConfigPath) expect(config).toContain("ci:") - expect(config).toContain("task:") + expect(config).not.toContain("task:") + expect(config).not.toContain("bug:") + expect(config).not.toContain("enhancement:") expect(config).not.toContain("P3:") expect(config).toContain("platform:") expect(config).toContain("app:") @@ -53,11 +56,11 @@ describe("pr triage workflow", () => { expect(config).not.toContain("**/*.md") }) - test("labels workflow PRs with type and routing while priority triage owns priority", () => { + test("labels workflow PRs with routing while scripts own priority and selected type inference", () => { const labels = labelerLabelsForGlob(".github/workflows/**") - expect(new Set(labels)).toEqual(new Set(["ci", "task"])) - expect(validateLabelPolicy({ itemType: "pull_request", labels: [...labels, "P3"] }).ok).toBe(true) + expect(new Set(labels)).toEqual(new Set(["ci"])) + expect(validateLabelPolicy({ itemType: "pull_request", labels: [...labels, "P3"] }).ok).toBe(false) }) test("pins pr-triage workflow contract: labeler, priority, and policy run serially", () => { @@ -70,7 +73,7 @@ describe("pr triage workflow", () => { expect(triageParsed.name).toBe("pr-triage") expect(triageParsed.on?.pull_request_target).toEqual({ - types: ["opened", "synchronize", "reopened"], + types: ["opened", "synchronize", "reopened", "labeled", "unlabeled"], branches: ["dev"], }) expect(triageParsed.permissions).toEqual({ @@ -100,7 +103,7 @@ describe("pr triage workflow", () => { expect(triageWorkflow).toContain("github.rest.issues.addLabels") expect(triageWorkflow).toContain("github.rest.issues.removeLabel") expect(triageWorkflow).not.toContain("github.rest.issues.setLabels") - expect(triageWorkflow).toContain("planPriorityLabels") + expect(triageWorkflow).toContain("planPullRequestLabels") expect(triageWorkflow).toContain("validateLabelPolicy") expect(triageWorkflow).toContain("github.rest.pulls.listReviews") expect(triageWorkflow).toContain("pathToFileURL") @@ -181,6 +184,50 @@ describe("pr priority triage helper", () => { }) }) + test("plans release bump-only type labels without overriding author-selected types", () => { + expect(planPullRequestLabels(["packages/desktop-electron/package.json"], ["platform"])).toEqual({ + suggestedPriority: "P2", + desiredPriority: "P2", + addLabels: ["P2", "task"], + removeLabels: [], + }) + + expect(planPullRequestLabels(["packages/desktop-electron/package.json", "bun.lock"], ["platform"])).toEqual({ + suggestedPriority: "P2", + desiredPriority: "P2", + addLabels: ["P2", "task"], + removeLabels: [], + }) + + expect( + planPullRequestLabels(["packages/desktop-electron/package.json", "bun.lock"], ["enhancement", "platform", "P2"]), + ).toEqual({ + suggestedPriority: "P2", + desiredPriority: "P2", + addLabels: [], + removeLabels: [], + }) + + expect( + planPullRequestLabels(["packages/desktop-electron/package.json", "packages/desktop-electron/README.md"], [ + "platform", + "P2", + ]), + ).toEqual({ + suggestedPriority: "P2", + desiredPriority: "P2", + addLabels: [], + removeLabels: [], + }) + + expect(planPullRequestLabels([".github/workflows/pr-triage.yml"], ["ci", "P3"])).toEqual({ + suggestedPriority: "P3", + desiredPriority: "P3", + addLabels: [], + removeLabels: [], + }) + }) + test("matches recent PR sanity cases", () => { expect( classifyPriority([