diff --git a/GLOSSARY.md b/GLOSSARY.md index 8030c88385..5d566def60 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -212,6 +212,24 @@ A child doing implementation work; one of the Prism roles. A child that reviews a candidate and returns a verdict. _Avoid_: approver +**Feature map**: +A project's `docs/map.md`: one Mermaid flowchart of its parts, each bound to +its Issues and PRs, which Chromeria's Map tab colours from GitHub. +_Avoid_: status map, roadmap, architecture diagram + +**Part**: +One node of a feature map: a piece of the project that is planned, working, +broken or done. +_Avoid_: feature, component, node (for the tracked piece) + +**Primary ref**: +The first Issue or PR on a part's ref line; it alone decides planned, working +or done. + +**Context part**: +A part a feature map draws but does not track, because another project owns +it. + ## Learning and observation **Capture**: diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 66e7ef1967..410ec0d5c8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -20,6 +20,7 @@ import { AuthRelayReadScope, AuthRelayWriteScope, AuthTerminalOperateScope, + FEATURE_MAP_WS_METHODS, ISSUE_WS_METHODS, ORCHESTRATION_V2_WS_METHODS, AuthTerminalReadScope, @@ -212,6 +213,8 @@ export const RPC_REQUIRED_SCOPES = { [ISSUE_WS_METHODS.issuesList]: AuthOrchestrationReadScope, [ISSUE_WS_METHODS.issuesDetail]: AuthOrchestrationReadScope, [ISSUE_WS_METHODS.issuesStates]: AuthOrchestrationReadScope, + // Fork: feature map (toolboxmd/chromeria#235). + [FEATURE_MAP_WS_METHODS.read]: AuthOrchestrationReadScope, // Fork: Issue links (toolboxmd/t3code#28). ...ISSUE_LINK_RPC_SCOPES, } as const satisfies Readonly>; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 453841c265..16b100ee32 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -248,6 +248,8 @@ export const make = Effect.gen(function* () { // Fork: GitHub Issues (toolboxmd/t3code#27) and their thread links (toolboxmd/t3code#28). issues: true, issueLinks: true, + // Fork: feature map (toolboxmd/chromeria#235). + featureMap: true, pullRequestStackActions: true, threadPullRequestLinking: true, serverResolvedCommandContext: true, diff --git a/apps/server/src/issues/IssueService.test.ts b/apps/server/src/issues/IssueService.test.ts index 866cb63942..abc027b2c4 100644 --- a/apps/server/src/issues/IssueService.test.ts +++ b/apps/server/src/issues/IssueService.test.ts @@ -185,3 +185,165 @@ describe("IssueService.states", () => { }), ); }); + +describe("IssueService.readFeatureMap", () => { + const contextmd: OrchestrationProjectShell = { + ...projects[0]!, + id: ProjectId.make("contextmd"), + workspaceRoot: "/work/contextmd", + repositoryIdentity: gitHubIdentity("toolboxmd/contextmd"), + }; + const map = [ + "# Map", + "", + "```mermaid", + "flowchart TD", + ' C["Capture"]', + " %% C: #7 PR #22", + ' SR["Serve report"]', + " %% SR: PR #38 #15", + " C --> SR", + "```", + ].join("\n"); + const head = JSON.stringify({ + data: { repository: { defaultBranchRef: { name: "main", target: { oid: "3f2a1c0abc" } } } }, + }); + const blob = (text: string | null) => + JSON.stringify({ data: { repository: { object: text === null ? null : { text } } } }); + const issuesPage = (numbers: ReadonlyArray, endCursor: string | null) => ({ + repository: { + issues: { + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + nodes: numbers.map((number) => ({ + number, + title: `Issue ${number}`, + url: `https://github.com/toolboxmd/contextmd/issues/${number}`, + })), + }, + }, + }); + const openIssue = { + __typename: "Issue", + url: "https://github.com/toolboxmd/contextmd/issues/7", + state: "OPEN", + stateReason: null, + labels: { pageInfo: { hasNextPage: false }, nodes: [] }, + closedByPullRequestsReferences: { pageInfo: { hasNextPage: false }, nodes: [] }, + }; + + it.effect( + "reads the map at the default branch's head commit, then every ref and open Issue", + () => + Effect.gen(function* () { + const requests = yield* Ref.make>([]); + const issues = yield* serviceWith([contextmd], (input) => + Ref.updateAndGet(requests, (all) => [...all, input]).pipe( + Effect.map(() => { + if (input.operation === "featureMapHead") return head; + if (input.operation === "featureMapFile") return blob(map); + const after = input.variables?.after ?? null; + return JSON.stringify({ + data: + after === null + ? { + ref0: openIssue, + ref1: null, + ref2: null, + ref3: null, + ...issuesPage([7, 9], "c1"), + } + : issuesPage([15], null), + }); + }), + ), + ); + + const snapshot = yield* issues.readFeatureMap({ projectId: contextmd.id }); + const sent = yield* Ref.get(requests); + expect(sent.map((input) => input.operation)).toEqual([ + "featureMapHead", + "featureMapFile", + "featureMapFacts", + "featureMapFacts", + ]); + // The file is read at the commit the head read returned, not at a moving branch name. + expect(sent[1]?.variables).toMatchObject({ expression: "3f2a1c0abc:docs/map.md" }); + if (snapshot._tag !== "Map") throw new Error(`expected a map, got ${snapshot._tag}`); + expect(snapshot).toMatchObject({ + host: "github.com", + repository: "toolboxmd/contextmd", + branch: "main", + commit: "3f2a1c0abc", + openIssuesPartial: false, + }); + expect(snapshot.parts.map((part) => part.id)).toEqual(["C", "SR"]); + expect(snapshot.facts.map((fact) => [fact.number, fact.fact?.kind ?? null])).toEqual([ + [7, "issue"], + [22, null], + [38, null], + [15, null], + ]); + expect(snapshot.openIssues.map((issue) => issue.number)).toEqual([7, 9, 15]); + }), + ); + + it.effect("answers NoMap when the default branch has no docs/map.md", () => + Effect.gen(function* () { + const issues = yield* serviceWith([contextmd], (input) => + Effect.succeed(input.operation === "featureMapHead" ? head : blob(null)), + ); + const snapshot = yield* issues.readFeatureMap({ projectId: contextmd.id }); + expect(snapshot).toMatchObject({ _tag: "NoMap", reason: "missing", commit: "3f2a1c0abc" }); + }), + ); + + it.effect("stops reading open Issues at the cap and marks the gap partial", () => + Effect.gen(function* () { + const facts = yield* Ref.make(0); + const issues = yield* serviceWith([contextmd], (input) => { + if (input.operation === "featureMapHead") return Effect.succeed(head); + if (input.operation === "featureMapFile") return Effect.succeed(blob(map)); + return Ref.updateAndGet(facts, (count) => count + 1).pipe( + Effect.map((count) => JSON.stringify({ data: issuesPage([count], `cursor-${count}`) })), + ); + }); + const snapshot = yield* issues.readFeatureMap({ projectId: contextmd.id }); + expect(yield* Ref.get(facts)).toBe(5); + expect(snapshot).toMatchObject({ _tag: "Map", openIssuesPartial: true }); + }), + ); + + it.effect("fails, rather than answering an empty map, when GitHub cannot be read", () => + Effect.gen(function* () { + const issues = yield* serviceWith([contextmd], (input) => + Effect.fail( + new GitHubApi.GitHubApiRequestError({ + host: input.host, + operation: input.operation, + cause: new Error("offline"), + }), + ), + ); + const error = yield* issues.readFeatureMap({ projectId: contextmd.id }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "IssueOperationError", + detail: "Could not reach GitHub at github.com.", + }); + }), + ); + + it.effect("refuses a project that is not on GitHub before any read", () => + Effect.gen(function* () { + let reads = 0; + const issues = yield* serviceWith([contextmd], () => { + reads++; + return Effect.succeed(head); + }); + const error = yield* issues + .readFeatureMap({ projectId: ProjectId.make("elsewhere") }) + .pipe(Effect.flip); + expect(error._tag).toBe("IssueOperationError"); + expect(reads).toBe(0); + }), + ); +}); diff --git a/apps/server/src/issues/IssueService.ts b/apps/server/src/issues/IssueService.ts index 014d332091..dedff08e3e 100644 --- a/apps/server/src/issues/IssueService.ts +++ b/apps/server/src/issues/IssueService.ts @@ -1,4 +1,7 @@ import { + type FeatureMapReadInput, + type FeatureMapRefFact, + type FeatureMapSnapshot, gitHubRepositoryOf, type IssueCommentInput, type IssueDetail, @@ -17,6 +20,7 @@ import { } from "@t3tools/contracts"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; @@ -24,6 +28,20 @@ import * as Result from "effect/Result"; import * as IssueLinks from "../issueLinks/IssueLinks.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import { + decodeFeatureMapFactsJson, + decodeFeatureMapFileJson, + decodeFeatureMapHeadJson, + FEATURE_MAP_FILE_QUERY, + FEATURE_MAP_HEAD_QUERY, + FEATURE_MAP_OPEN_ISSUE_PAGES, + FEATURE_MAP_PATH, + FEATURE_MAP_REFS_PER_READ, + featureMapFactsDocument, + featureMapFactsOf, + featureMapRefsOf, + parseFeatureMapFile, +} from "./featureMap.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { type GraphQlDocument, readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; import { @@ -63,9 +81,22 @@ export class IssueService extends Context.Service< ) => Effect.Effect; readonly comment: (input: IssueCommentInput) => Effect.Effect; readonly setState: (input: IssueSetStateInput) => Effect.Effect; + /** A project's feature map and its refs' GitHub facts, read as one snapshot. */ + readonly readFeatureMap: ( + input: FeatureMapReadInput, + ) => Effect.Effect; } >()("t3/issues/IssueService") {} +type OpenIssuePage = { + readonly pageInfo: { readonly hasNextPage: boolean; readonly endCursor: string | null }; + readonly nodes: ReadonlyArray<{ + readonly number: number; + readonly title: string; + readonly url: string; + } | null>; +}; + interface Workspace { readonly repositories: ReadonlyArray; readonly unsupported: ReadonlyArray<{ readonly host: string; readonly repository: string }>; @@ -549,7 +580,122 @@ const make = Effect.gen(function* () { }, }); - return IssueService.of({ list, detail, states, comment, setState }); + /** + * The project's `docs/map.md` at its default branch's head commit, every ref it names, and the + * repository's open Issues, under one pinned credential. The file is read by commit, so a push + * between the reads cannot pair one version's parts with another's file. + */ + const readFeatureMap: IssueService["Service"]["readFeatureMap"] = (input) => + Effect.gen(function* () { + const shells = yield* projects + .listShells() + .pipe( + Effect.mapError((cause) => + failure("listProjects", cause, "The project list could not be read."), + ), + ); + const shell = shells.find((candidate) => candidate.id === input.projectId); + const identity = + shell === undefined + ? null + : (shell.repositoryIdentity ?? + (yield* repositoryIdentities.resolve(shell.workspaceRoot))); + const gitHub = identity ? gitHubRepositoryOf(identity) : null; + if (gitHub === null) { + return yield* new IssueOperationError({ + operation: "readFeatureMap", + detail: "This project has no GitHub repository to read a map from.", + }); + } + const { host, repository } = gitHub; + const { owner, name } = repositoryParts(repository); + const pinned = yield* pinnedCredentials([host]); + return yield* pinned( + host, + Effect.gen(function* () { + const headAnswer = yield* graphqlRead({ + host, + operation: "featureMapHead", + document: { query: FEATURE_MAP_HEAD_QUERY, variables: { owner, name } }, + decode: decodeFeatureMapHeadJson, + }); + const head = headAnswer.data.repository?.defaultBranchRef ?? null; + if (head === null) { + return yield* new IssueOperationError({ + operation: "readFeatureMap", + detail: `${repository} has no default branch GitHub can read.`, + }); + } + const location = { + host, + repository, + branch: head.name, + commit: head.target.oid, + }; + const fileAnswer = yield* graphqlRead({ + host, + operation: "featureMapFile", + document: { + query: FEATURE_MAP_FILE_QUERY, + variables: { owner, name, expression: `${head.target.oid}:${FEATURE_MAP_PATH}` }, + }, + decode: decodeFeatureMapFileJson, + }); + const text = fileAnswer.data.repository?.object?.text ?? null; + const parsed = text === null ? null : parseFeatureMapFile(text, { host, repository }); + if (parsed === null) { + return { + _tag: "NoMap" as const, + ...location, + reason: text === null ? ("missing" as const) : ("no-diagram" as const), + fetchedAt: DateTime.formatIso(yield* DateTime.now), + }; + } + + const refs = featureMapRefsOf(parsed.parts); + const facts: Array = []; + const openIssues: Array<{ number: number; title: string; url: string }> = []; + let after: string | null = null; + let issuePages = 0; + let moreIssues = true; + // Each request carries the next refs and the next open-Issue page, until both run out. + for ( + let start = 0; + start < refs.length || (moreIssues && issuePages < FEATURE_MAP_OPEN_ISSUE_PAGES); + start += FEATURE_MAP_REFS_PER_READ + ) { + const chunk = refs.slice(start, start + FEATURE_MAP_REFS_PER_READ); + const answer: { + readonly openIssues: OpenIssuePage | null; + readonly aliases: Readonly>; + } = yield* graphqlRead({ + host, + operation: "featureMapFacts", + document: featureMapFactsDocument({ refs: chunk, repository, after }), + decode: decodeFeatureMapFactsJson, + }); + facts.push(...featureMapFactsOf(chunk, answer.aliases)); + if (!moreIssues || issuePages >= FEATURE_MAP_OPEN_ISSUE_PAGES) continue; + issuePages++; + const page = answer.openIssues; + for (const issue of page?.nodes ?? []) if (issue !== null) openIssues.push(issue); + after = page?.pageInfo.endCursor ?? null; + moreIssues = page?.pageInfo.hasNextPage === true && after !== null; + } + return { + _tag: "Map" as const, + ...location, + ...parsed, + facts, + openIssues, + openIssuesPartial: moreIssues, + fetchedAt: DateTime.formatIso(yield* DateTime.now), + }; + }), + ); + }); + + return IssueService.of({ list, detail, states, comment, setState, readFeatureMap }); }); export const layer = Layer.effect(IssueService, make); diff --git a/apps/server/src/issues/featureMap.test.ts b/apps/server/src/issues/featureMap.test.ts new file mode 100644 index 0000000000..e99668c61b --- /dev/null +++ b/apps/server/src/issues/featureMap.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + featureMapFactsDocument, + featureMapFactsOf, + FEATURE_MAP_OPEN_ISSUE_PAGE, + parseFeatureMapFile, +} from "./featureMap.ts"; + +const at = { host: "github.com", repository: "toolboxmd/contextmd" }; + +const file = (body: string) => `# Map\n\nIntro.\n\n\`\`\`mermaid\n${body}\n\`\`\`\n`; + +describe("parseFeatureMapFile", () => { + it("reads parts, their refs in order, and context parts", () => { + const parsed = parseFeatureMapFile( + file(`%%{init: {"theme": "dark"}}%% +flowchart TD + S["Session in Chromeria"] + %% S: context + C["2 Capture: extract and judge"] + %% C: #7 PR #22 + MK["Must-know rule before its action"] + %% MK: toolboxmd/chromeria PR #233 toolboxmd/chromeria#231 + LJ["6 Labeler judge runs"] + S --> C --> MK + C -->|"next"| LJ`), + at, + ); + if (parsed === null) throw new Error("expected a diagram"); + expect(parsed.source.startsWith("%%{init")).toBe(true); + expect(parsed.source.endsWith('C -->|"next"| LJ')).toBe(true); + expect(parsed.parts).toEqual([ + { id: "S", name: "Session in Chromeria", context: true, primaryUnreadable: false, refs: [] }, + { + id: "C", + name: "2 Capture: extract and judge", + context: false, + primaryUnreadable: false, + refs: [ + { + kind: "issue", + repository: "toolboxmd/contextmd", + number: 7, + url: "https://github.com/toolboxmd/contextmd/issues/7", + }, + { + kind: "pull", + repository: "toolboxmd/contextmd", + number: 22, + url: "https://github.com/toolboxmd/contextmd/pull/22", + }, + ], + }, + { + id: "MK", + name: "Must-know rule before its action", + context: false, + primaryUnreadable: false, + refs: [ + { + kind: "pull", + repository: "toolboxmd/chromeria", + number: 233, + url: "https://github.com/toolboxmd/chromeria/pull/233", + }, + { + kind: "issue", + repository: "toolboxmd/chromeria", + number: 231, + url: "https://github.com/toolboxmd/chromeria/issues/231", + }, + ], + }, + { + id: "LJ", + name: "6 Labeler judge runs", + context: false, + primaryUnreadable: false, + refs: [], + }, + ]); + expect(parsed.undeclaredRefLines).toEqual([]); + expect(parsed.unreadableRefs).toEqual([]); + }); + + it("binds a ref line written before its part's declaration", () => { + const parsed = parseFeatureMapFile(file(`flowchart TD\n %% A: #1\n A["Alpha"]`), at); + expect(parsed?.parts.map((part) => [part.id, part.refs.map((ref) => ref.number)])).toEqual([ + ["A", [1]], + ]); + }); + + it("reports ref lines for undeclared IDs and tokens it cannot read, keeping the rest", () => { + const parsed = parseFeatureMapFile( + file(`flowchart TD + A["Alpha"] + %% A: #3 issue-9 PR #4 PR + %% GHOST: #5`), + at, + ); + expect(parsed?.parts[0]?.refs.map((ref) => `${ref.kind} ${ref.number}`)).toEqual([ + "issue 3", + "pull 4", + ]); + expect(parsed?.undeclaredRefLines).toEqual([{ id: "GHOST", text: "#5" }]); + expect(parsed?.unreadableRefs).toEqual([ + { id: "A", text: "issue-9" }, + { id: "A", text: "PR" }, + ]); + }); + + it("marks a part whose primary ref cannot be read, instead of promoting the next ref", () => { + const parsed = parseFeatureMapFile( + file(`flowchart TD\n A["Alpha"]\n %% A: #0 PR #38\n B["Beta"]\n %% B: #1 bogus`), + at, + ); + expect(parsed?.parts.map((part) => [part.id, part.primaryUnreadable])).toEqual([ + ["A", true], + ["B", false], + ]); + }); + + it("finds no diagram in a file without a mermaid fence", () => { + expect(parseFeatureMapFile("# Map\n\n```text\nA --> B\n```\n", at)).toBeNull(); + }); +}); + +describe("featureMapFactsDocument", () => { + it("reads every ref as either type and the repository's open Issues, all as variables", () => { + const document = featureMapFactsDocument({ + refs: [ + { kind: "issue", repository: "toolboxmd/contextmd", number: 7, url: "u7" }, + { kind: "pull", repository: "toolboxmd/contextmd", number: 22, url: "u22" }, + ], + repository: "toolboxmd/contextmd", + after: null, + }); + expect(document.query).toContain("ref0: resource(url: $ref0_url)"); + expect(document.query).toContain("ref1: resource(url: $ref1_url)"); + expect(document.query).toContain("... on Issue"); + expect(document.query).toContain("... on PullRequest"); + expect(document.query).toContain(`issues(states: OPEN, first: ${FEATURE_MAP_OPEN_ISSUE_PAGE}`); + expect(document.variables).toMatchObject({ + ref0_url: "u7", + ref1_url: "u22", + owner: "toolboxmd", + name: "contextmd", + after: null, + }); + }); + + it("reads only the open Issues' next page when there are no refs left", () => { + const document = featureMapFactsDocument({ + refs: [], + repository: "toolboxmd/contextmd", + after: "cursor", + }); + expect(document.query).not.toContain("resource("); + expect(document.variables).toMatchObject({ after: "cursor" }); + }); +}); + +describe("featureMapFactsOf", () => { + const refs = [ + { kind: "issue", repository: "toolboxmd/contextmd", number: 7, url: "u7" }, + { kind: "pull", repository: "toolboxmd/contextmd", number: 38, url: "u38" }, + { kind: "pull", repository: "toolboxmd/contextmd", number: 40, url: "u40" }, + { kind: "issue", repository: "lukemaj/private", number: 2, url: "u2" }, + { kind: "issue", repository: "toolboxmd/contextmd", number: 44, url: "u44" }, + ] as const; + + it("folds each answer into a fact, and anything GitHub did not answer into null", () => { + const facts = featureMapFactsOf(refs, { + ref0: { + __typename: "Issue", + url: "https://github.com/toolboxmd/contextmd/issues/7", + state: "OPEN", + stateReason: null, + labels: { pageInfo: { hasNextPage: false }, nodes: [{ name: "bug" }] }, + closedByPullRequestsReferences: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + number: 22, + url: "https://github.com/toolboxmd/contextmd/pull/22", + state: "OPEN", + isDraft: true, + repository: { nameWithOwner: "toolboxmd/contextmd" }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + { + number: 21, + url: "https://github.com/toolboxmd/contextmd/pull/21", + state: "CLOSED", + isDraft: false, + repository: { nameWithOwner: "toolboxmd/contextmd" }, + commits: { nodes: [] }, + }, + null, + ], + }, + }, + ref1: { + __typename: "PullRequest", + url: "https://github.com/toolboxmd/contextmd/pull/38", + state: "MERGED", + isDraft: false, + baseRefName: "main", + baseRepository: { defaultBranchRef: { name: "main" } }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }, + ref2: { + __typename: "PullRequest", + url: "https://github.com/toolboxmd/contextmd/pull/40", + state: "MERGED", + isDraft: false, + baseRefName: "feat/stack-base", + baseRepository: { defaultBranchRef: { name: "main" } }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + }, + ref3: null, + // A resource of neither type answers an empty object. + ref4: {}, + }); + expect(facts).toEqual([ + { + repository: "toolboxmd/contextmd", + number: 7, + fact: { + kind: "issue", + url: "https://github.com/toolboxmd/contextmd/issues/7", + state: "open", + labels: ["bug"], + complete: true, + openClosingPullRequests: [ + { + repository: "toolboxmd/contextmd", + number: 22, + url: "https://github.com/toolboxmd/contextmd/pull/22", + isDraft: true, + checks: "failing", + }, + ], + }, + }, + { + repository: "toolboxmd/contextmd", + number: 38, + fact: { + kind: "pull", + url: "https://github.com/toolboxmd/contextmd/pull/38", + state: "merged", + isDraft: false, + mergedIntoDefault: true, + checks: "passing", + }, + }, + { + repository: "toolboxmd/contextmd", + number: 40, + fact: { + kind: "pull", + url: "https://github.com/toolboxmd/contextmd/pull/40", + state: "merged", + isDraft: false, + mergedIntoDefault: false, + checks: null, + }, + }, + { repository: "lukemaj/private", number: 2, fact: null }, + { repository: "toolboxmd/contextmd", number: 44, fact: null }, + ]); + }); + + it("marks an Issue incomplete when GitHub holds more labels or closing PRs than it returned", () => { + const answer = (labelsMore: boolean, closingMore: boolean) => ({ + ref0: { + __typename: "Issue", + url: "u", + state: "OPEN", + stateReason: null, + labels: { pageInfo: { hasNextPage: labelsMore }, nodes: [{ name: "area" }] }, + closedByPullRequestsReferences: { pageInfo: { hasNextPage: closingMore }, nodes: [] }, + }, + }); + const completeOf = (labelsMore: boolean, closingMore: boolean) => { + const fact = featureMapFactsOf([refs[0]], answer(labelsMore, closingMore))[0]?.fact; + return fact?.kind === "issue" ? fact.complete : null; + }; + expect(completeOf(false, false)).toBe(true); + expect(completeOf(true, false)).toBe(false); + expect(completeOf(false, true)).toBe(false); + }); + + it("reads a closed-not-planned Issue as not planned", () => { + const [entry] = featureMapFactsOf([refs[0]], { + ref0: { + __typename: "Issue", + url: "u", + state: "CLOSED", + stateReason: "NOT_PLANNED", + labels: { pageInfo: { hasNextPage: false }, nodes: [] }, + closedByPullRequestsReferences: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + }); + expect(entry?.fact).toMatchObject({ kind: "issue", state: "not-planned" }); + }); +}); diff --git a/apps/server/src/issues/featureMap.ts b/apps/server/src/issues/featureMap.ts new file mode 100644 index 0000000000..9b4399bf09 --- /dev/null +++ b/apps/server/src/issues/featureMap.ts @@ -0,0 +1,424 @@ +import type { + FeatureMapChecks, + FeatureMapPart, + FeatureMapRef, + FeatureMapRefFact, + FeatureMapRefLineProblem, +} from "@t3tools/contracts"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts"; +import { issueStateOf, repositoryParts } from "./gitHubIssues.ts"; + +/** Where a project keeps its feature map, on its default branch. */ +export const FEATURE_MAP_PATH = "docs/map.md"; +/** Open Issues read per page for the "open Issues on no part" gap. */ +export const FEATURE_MAP_OPEN_ISSUE_PAGE = 100; +/** Open-Issue pages read before the gap is marked partial (500 Issues). */ +export const FEATURE_MAP_OPEN_ISSUE_PAGES = 5; +/** Refs read per request. */ +export const FEATURE_MAP_REFS_PER_READ = 100; +/** Closing pull requests read per Issue; more marks the Issue's facts incomplete. */ +const CLOSING_PULL_REQUEST_PAGE = 10; +/** Labels read per Issue, GitHub's page maximum; labels add no cost per page size. */ +const LABEL_PAGE = 100; + +const FENCE = /^```mermaid[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/m; +const PART = /^\s*([A-Za-z]\w*)\s*\["([^"]*)"\]\s*;?\s*$/; +// `%%{init}%%` directives start with a brace, so they never match an ID. +const REF_LINE = /^\s*%%\s*([A-Za-z]\w*)\s*:(.*)$/; +const REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const NUMBER = /^#(\d+)$/; +const REPOSITORY_NUMBER = /^([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)#(\d+)$/; + +export interface ParsedFeatureMap { + readonly source: string; + readonly parts: ReadonlyArray; + readonly undeclaredRefLines: ReadonlyArray; + readonly unreadableRefs: ReadonlyArray; +} + +function refOf( + host: string, + kind: FeatureMapRef["kind"], + repository: string, + number: string, +): FeatureMapRef | null { + const value = Number(number); + if (!Number.isSafeInteger(value) || value < 1) return null; + return { + kind, + repository, + number: value, + url: `https://${host}/${repository}/${kind === "issue" ? "issues" : "pull"}/${value}`, + }; +} + +/** + * The refs on one `%% ID:` line: `#7`, `PR #38`, `owner/repo#231` or `owner/repo PR #227`, all on + * `host`; anything else is reported, never guessed. + */ +function readRefs(text: string, at: { readonly host: string; readonly repository: string }) { + const tokens = text.trim().split(/\s+/).filter(Boolean); + const refs: Array = []; + const unreadable: Array = []; + let firstUnreadable = false; + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]!; + let repository = at.repository; + let cursor = index; + if (REPOSITORY.test(token) && !token.includes("#")) { + repository = token; + cursor++; + } + const pull = tokens[cursor] === "PR"; + if (pull) cursor++; + const qualified = !pull && cursor === index ? REPOSITORY_NUMBER.exec(token) : null; + const number = qualified?.[2] ?? NUMBER.exec(tokens[cursor] ?? "")?.[1]; + const ref = + number === undefined + ? null + : refOf(at.host, pull ? "pull" : "issue", qualified?.[1] ?? repository, number); + if (ref === null) { + if (refs.length === 0 && unreadable.length === 0) firstUnreadable = true; + unreadable.push(token); + continue; + } + refs.push(ref); + index = cursor; + } + return { refs, unreadable, firstUnreadable }; +} + +/** + * The first fenced `mermaid` block of `docs/map.md`: each `ID["Name"]` line declares a part, and + * each `%% ID: refs` line binds it (first ref primary) or marks it `context`. Edges and other lines + * stay plain Mermaid. Null when the file holds no such block. + */ +export function parseFeatureMapFile( + markdown: string, + at: { readonly host: string; readonly repository: string }, +): ParsedFeatureMap | null { + const source = FENCE.exec(markdown)?.[1]; + if (source === undefined) return null; + const parts = new Map(); + const lines: Array<{ id: string; text: string }> = []; + for (const line of source.split(/\r?\n/)) { + const part = PART.exec(line); + if (part !== null) { + if (!parts.has(part[1]!)) parts.set(part[1]!, { name: part[2]! }); + continue; + } + const refLine = REF_LINE.exec(line); + if (refLine !== null) lines.push({ id: refLine[1]!, text: refLine[2]!.trim() }); + } + const bound = new Map< + string, + { context: boolean; primaryUnreadable: boolean; refs: Array } + >(); + const undeclaredRefLines: Array = []; + const unreadableRefs: Array = []; + for (const { id, text } of lines) { + if (!parts.has(id)) { + undeclaredRefLines.push({ id, text }); + continue; + } + const binding = bound.get(id) ?? { context: false, primaryUnreadable: false, refs: [] }; + if (text === "context") { + binding.context = true; + } else { + const { refs, unreadable, firstUnreadable } = readRefs(text, at); + // The first token is the primary ref; a later ref must not take its place. + if (binding.refs.length === 0 && firstUnreadable) binding.primaryUnreadable = true; + binding.refs.push(...refs); + unreadableRefs.push(...unreadable.map((token) => ({ id, text: token }))); + } + bound.set(id, binding); + } + return { + source, + parts: [...parts].map(([id, { name }]) => ({ + id, + name, + context: bound.get(id)?.context ?? false, + primaryUnreadable: bound.get(id)?.primaryUnreadable ?? false, + refs: bound.get(id)?.refs ?? [], + })), + undeclaredRefLines, + unreadableRefs, + }; +} + +/** Each ref once, in first-seen order. */ +export function featureMapRefsOf(parts: ReadonlyArray): Array { + const seen = new Map(); + for (const part of parts) { + for (const ref of part.refs) { + const key = `${ref.repository.toLowerCase()}#${ref.number}`; + if (!seen.has(key)) seen.set(key, ref); + } + } + return [...seen.values()]; +} + +/** The default branch and its head commit. */ +export const FEATURE_MAP_HEAD_QUERY = `query FeatureMapHead($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { name target { oid } } + } +}`; + +export const decodeFeatureMapHeadJson = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + defaultBranchRef: Schema.NullOr( + Schema.Struct({ name: Schema.String, target: Schema.Struct({ oid: Schema.String }) }), + ), + }), + ), + }), + }), + ), +); + +/** `docs/map.md` at exactly that commit; a missing file answers null, not an error. */ +export const FEATURE_MAP_FILE_QUERY = `query FeatureMapFile($owner: String!, $name: String!, $expression: String!) { + repository(owner: $owner, name: $name) { + object(expression: $expression) { ... on Blob { text } } + } +}`; + +export const decodeFeatureMapFileJson = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + object: Schema.NullOr( + Schema.Struct({ text: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + }), + ), + }), + }), + ), +); + +const HEAD_CHECKS = "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; + +// Both fragments, so a ref written as an Issue that is a pull request (or the reverse) still reads. +const REF_FIELDS = `__typename + ... on Issue { + url state stateReason + labels(first: ${LABEL_PAGE}) { pageInfo { hasNextPage } nodes { name } } + closedByPullRequestsReferences(first: ${CLOSING_PULL_REQUEST_PAGE}) { + pageInfo { hasNextPage } + nodes { number url state isDraft repository { nameWithOwner } ${HEAD_CHECKS} } + } + } + ... on PullRequest { + url state isDraft baseRefName + baseRepository { defaultBranchRef { name } } + ${HEAD_CHECKS} + }`; + +const OPEN_ISSUES = ` repository(owner: $owner, name: $name) { + issues(states: OPEN, first: ${FEATURE_MAP_OPEN_ISSUE_PAGE}, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { number title url } + } + }`; + +/** + * Up to `FEATURE_MAP_REFS_PER_READ` refs, each through `resource(url:)` (null for a private or + * deleted item instead of failing the request), plus one page of the repository's open Issues. + */ +export function featureMapFactsDocument(input: { + readonly refs: ReadonlyArray; + readonly repository: string; + readonly after: string | null; +}): GraphQlDocument { + const { owner, name } = repositoryParts(input.repository); + const shared = { + owner: ["String!", owner], + name: ["String!", name], + after: ["String", input.after], + } as const; + return ( + aliasedGraphQlDocument({ + operation: "query", + name: "FeatureMapFacts", + alias: "ref", + items: input.refs, + variables: (ref) => ({ url: ["URI!", ref.url] }), + field: ({ url }) => `resource(url: ${url}) {\n ${REF_FIELDS}\n }`, + shared, + within: (fields) => `${fields}\n${OPEN_ISSUES}`, + }) ?? { + query: `query FeatureMapFacts($owner: String!, $name: String!, $after: String) {\n${OPEN_ISSUES}\n}`, + variables: { owner, name, after: input.after }, + } + ); +} + +const Checks = Schema.Struct({ + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + commit: Schema.Struct({ + statusCheckRollup: Schema.NullOr(Schema.Struct({ state: Schema.String })), + }), + }), + ), + ), +}); + +const IssueAnswer = Schema.Struct({ + __typename: Schema.Literal("Issue"), + url: Schema.String, + state: Schema.String, + stateReason: Schema.NullOr(Schema.String), + labels: Schema.Struct({ + pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), + nodes: Schema.Array(Schema.NullOr(Schema.Struct({ name: Schema.String }))), + }), + closedByPullRequestsReferences: Schema.Struct({ + pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + number: Schema.Number, + url: Schema.String, + state: Schema.String, + isDraft: Schema.Boolean, + repository: Schema.Struct({ nameWithOwner: Schema.String }), + commits: Checks, + }), + ), + ), + }), +}); + +const PullRequestAnswer = Schema.Struct({ + __typename: Schema.Literal("PullRequest"), + url: Schema.String, + state: Schema.String, + isDraft: Schema.Boolean, + baseRefName: Schema.String, + baseRepository: Schema.NullOr( + Schema.Struct({ defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })) }), + ), + commits: Checks, +}); + +// Anything else (null, `{}` for another type) reads as no answer. +const decodeRefAnswer = Schema.decodeUnknownOption(Schema.Union([IssueAnswer, PullRequestAnswer])); + +const FactsJson = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + issues: Schema.Struct({ + pageInfo: Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.NullOr(Schema.String), + }), + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ number: Schema.Number, title: Schema.String, url: Schema.String }), + ), + ), + }), + }), + ), + }), +}); +const decodeFactsJson = Schema.decodeUnknownResult(Schema.fromJsonString(FactsJson)); +const decodeAliases = Schema.decodeUnknownResult( + Schema.fromJsonString(Schema.Struct({ data: Schema.Record(Schema.String, Schema.Unknown) })), +); + +/** The open-Issue page and every alias (`ref`, read by `featureMapFactsOf`) of one answer. */ +export const decodeFeatureMapFactsJson = (raw: string) => + Result.flatMap(decodeFactsJson(raw), (typed) => + Result.map(decodeAliases(raw), (aliases) => ({ + openIssues: typed.data.repository?.issues ?? null, + aliases: aliases.data, + })), + ); + +function checksOf(commits: typeof Checks.Type): FeatureMapChecks { + switch (commits.nodes.at(-1)?.commit.statusCheckRollup?.state) { + case "SUCCESS": + return "passing"; + case "FAILURE": + case "ERROR": + return "failing"; + case "PENDING": + case "EXPECTED": + return "pending"; + default: + return null; + } +} + +function pullRequestStateOf(state: string) { + return state === "MERGED" ? "merged" : state === "OPEN" ? "open" : "closed"; +} + +/** Each ref's fact from the answer's `ref` aliases, by position. */ +export function featureMapFactsOf( + refs: ReadonlyArray, + data: Readonly>, +): Array { + return refs.map((ref, index) => { + const answer = decodeRefAnswer(data[`ref${index}`]); + const base = { repository: ref.repository, number: ref.number }; + if (answer._tag === "None") return { ...base, fact: null }; + const node = answer.value; + if (node.__typename === "PullRequest") { + const state = pullRequestStateOf(node.state); + const defaultBranch = node.baseRepository?.defaultBranchRef?.name ?? null; + return { + ...base, + fact: { + kind: "pull", + url: node.url, + state, + isDraft: node.isDraft, + mergedIntoDefault: state === "merged" && node.baseRefName === defaultBranch, + checks: checksOf(node.commits), + }, + }; + } + return { + ...base, + fact: { + kind: "issue", + url: node.url, + state: issueStateOf(node), + labels: node.labels.nodes.flatMap((label) => (label === null ? [] : [label.name])), + complete: + !node.labels.pageInfo.hasNextPage && + !node.closedByPullRequestsReferences.pageInfo.hasNextPage, + openClosingPullRequests: node.closedByPullRequestsReferences.nodes.flatMap((pullRequest) => + pullRequest === null || pullRequest.state !== "OPEN" + ? [] + : [ + { + repository: pullRequest.repository.nameWithOwner, + number: pullRequest.number, + url: pullRequest.url, + isDraft: pullRequest.isDraft, + checks: checksOf(pullRequest.commits), + }, + ], + ), + }, + }; + }); +} diff --git a/apps/server/src/issues/issueRpcHandlers.ts b/apps/server/src/issues/issueRpcHandlers.ts index d65ac3898a..d6520b1e35 100644 --- a/apps/server/src/issues/issueRpcHandlers.ts +++ b/apps/server/src/issues/issueRpcHandlers.ts @@ -1,4 +1,6 @@ import { + FEATURE_MAP_WS_METHODS, + type FeatureMapReadInput, ISSUE_LINKS_WS_METHODS, type IssueCommentInput, type IssueListInput, @@ -18,6 +20,7 @@ export function makeIssueRpcHandlers(issues: IssueService["Service"]) { [ISSUE_WS_METHODS.issuesStates]: (input: IssueStatesInput) => issues.states(input), [ISSUE_WS_METHODS.issuesComment]: (input: IssueCommentInput) => issues.comment(input), [ISSUE_WS_METHODS.issuesSetState]: (input: IssueSetStateInput) => issues.setState(input), + [FEATURE_MAP_WS_METHODS.read]: (input: FeatureMapReadInput) => issues.readFeatureMap(input), }; } @@ -28,6 +31,7 @@ export const ISSUE_RPC_AGGREGATES = { [ISSUE_WS_METHODS.issuesStates]: "issues", [ISSUE_WS_METHODS.issuesComment]: "issues", [ISSUE_WS_METHODS.issuesSetState]: "issues", + [FEATURE_MAP_WS_METHODS.read]: "featureMap", [ISSUE_LINKS_WS_METHODS.forThread]: "issueLinks", [ISSUE_LINKS_WS_METHODS.threadsForIssues]: "issueLinks", [ISSUE_LINKS_WS_METHODS.link]: "issueLinks", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a3a2ba6266..fbcaf3d2f0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -290,6 +290,7 @@ import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavaila import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { ThreadIssuePanel } from "./issues/ThreadIssuePanel"; +import { FeatureMapPanel } from "./featureMap/FeatureMapPanel"; import { ThreadLinksPanel } from "./issues/ThreadLinksPanel"; import { useDeviceState } from "~/state/device"; import { DeviceSetup } from "./device/DeviceSetup"; @@ -5467,6 +5468,13 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !linksSurfaceAvailable) return; useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); }, [activeThreadRef, linksSurfaceAvailable]); + // Fork: the project's feature map (toolboxmd/chromeria#235). + const featureMapSupported = serverConfig?.environment.capabilities.featureMap === true; + const mapSurfaceAvailable = isServerThread && activeProject != null && featureMapSupported; + const addMapSurface = useCallback(() => { + if (!activeThreadRef || !mapSurfaceAvailable) return; + useRightPanelStore.getState().open(activeThreadRef, "map"); + }, [activeThreadRef, mapSurfaceAvailable]); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); @@ -11057,6 +11065,14 @@ export default function ChatView(props: ChatViewProps) { threadRef={activeThreadRef} issueLinks={serverConfig?.environment.capabilities.issueLinks === true} /> + ) : renderedRightPanelSurface?.kind === "map" && activeThreadRef ? ( + ) : renderedRightPanelSurface?.kind === "issue" ? ( {rightPanelContent} @@ -12027,6 +12045,7 @@ export default function ChatView(props: ChatViewProps) { onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} onAddDevice={addDeviceSurface} + onAddMap={addMapSurface} browserAvailable={canOperatePreview && browserAvailable} terminalAvailable={activeProject !== null && canOperateTerminal} diffAvailable={isServerThread && isGitRepo} @@ -12034,6 +12053,7 @@ export default function ChatView(props: ChatViewProps) { pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={linksSurfaceAvailable} deviceAvailable={activeThreadRef !== null} + mapAvailable={mapSurfaceAvailable} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5b316c2fee..a7ac6e4d8a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -20,6 +20,7 @@ import type { import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { CircleDot, + Network, Smartphone, ChevronDown, ChevronLeft, @@ -133,6 +134,8 @@ interface RightPanelTabsProps { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Fork: the project's feature map (toolboxmd/chromeria#235); absent where there is none. */ + onAddMap?: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -140,6 +143,7 @@ interface RightPanelTabsProps { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + mapAvailable?: boolean; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -168,6 +172,7 @@ const SURFACE_DISABLED_REASONS = { pullRequest: "This thread's branch has no pull request yet.", pullRequests: "No linked pull requests are available for this thread.", device: "Devices are only available from a thread.", + map: "The map is only available for a GitHub project on a server that reads it.", } as const; /** Overlays that must win over the launcher's letter shortcuts. */ @@ -191,6 +196,7 @@ const SURFACE_UNAVAILABLE_HINTS = { pullRequest: "No pull request on this branch yet.", pullRequests: "No linked pull requests available.", device: "Available from a thread.", + map: "Available for GitHub projects.", } as const; type TabContextMenuAction = @@ -330,6 +336,8 @@ function RightPanelEmptyState(props: { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Fork: the project's feature map (toolboxmd/chromeria#235); absent where there is none. */ + onAddMap?: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -337,6 +345,7 @@ function RightPanelEmptyState(props: { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + mapAvailable?: boolean; }) { // -1 means no highlight: it only appears on hover or arrow use. const [highlight, setHighlight] = useState(-1); @@ -400,6 +409,15 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_UNAVAILABLE_HINTS.device, onClick: props.onAddDevice, }, + { + // Fork: the project's feature map (toolboxmd/chromeria#235). + label: "Map", + icon: Network, + shortcut: "A", + available: props.mapAvailable === true && props.onAddMap !== undefined, + disabledReason: SURFACE_UNAVAILABLE_HINTS.map, + onClick: () => props.onAddMap?.(), + }, ] as const; type SurfaceAction = (typeof actions)[number]; @@ -613,6 +631,8 @@ function surfaceTitle( const issue = parseIssueUrl(surface.url); return issue === null ? "Issue" : `#${issue.number}`; } + case "map": + return "Map"; case "device": return surface.title ?? surface.target?.name ?? "Device"; case "preview": { @@ -698,6 +718,8 @@ function SurfaceIcon({ return ; case "issue": return ; + case "map": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -934,6 +956,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.device, onClick: props.onAddDevice, }, + { + // Fork: the project's feature map (toolboxmd/chromeria#235). + label: "Map", + icon: Network, + shortcut: "A", + available: props.mapAvailable === true && props.onAddMap !== undefined, + disabledReason: SURFACE_DISABLED_REASONS.map, + onClick: () => props.onAddMap?.(), + }, ] as const; const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { @@ -1417,6 +1448,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddPullRequest={props.onAddPullRequest} onAddPullRequests={props.onAddPullRequests} onAddDevice={props.onAddDevice} + {...(props.onAddMap === undefined ? {} : { onAddMap: props.onAddMap })} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} @@ -1424,6 +1456,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { pullRequestAvailable={props.pullRequestAvailable} pullRequestsAvailable={props.pullRequestsAvailable} deviceAvailable={props.deviceAvailable} + mapAvailable={props.mapAvailable === true} /> ) : ( props.children diff --git a/apps/web/src/components/chat/MermaidDiagram.tsx b/apps/web/src/components/chat/MermaidDiagram.tsx index dd03bdc4ea..9d27fcb27c 100644 --- a/apps/web/src/components/chat/MermaidDiagram.tsx +++ b/apps/web/src/components/chat/MermaidDiagram.tsx @@ -1,6 +1,6 @@ import DOMPurify from "dompurify"; import type { Mermaid } from "mermaid"; -import { use, useState } from "react"; +import { use, useLayoutEffect, useRef, useState } from "react"; import { Button } from "../ui/button"; @@ -160,18 +160,31 @@ function mermaidImageUrl(svg: string): string { return expandedImageUrl; } -/** Suspends until the diagram renders; failures show the parser message above the source. */ +/** + * Suspends until the diagram renders; failures show the parser message above the source. In chat + * the diagram is a button that expands it; with `onRendered` it is a plain container whose SVG is + * handed over once mounted, for a view that decorates the drawing itself. + */ export function MermaidDiagram({ source, theme, - onExpand, + ...mode }: { source: string; theme: "light" | "dark"; - onExpand: (imageUrl: string) => void; -}) { +} & ( + | { onExpand: (imageUrl: string) => void; onRendered?: never } + | { onRendered: (svg: SVGSVGElement) => void; onExpand?: never } +)) { const [, setAttempt] = useState(0); const result = use(mermaidRenderPromise(source.trim(), theme)); + const containerRef = useRef(null); + const onRendered = mode.onRendered; + const renderedSvg = result.status === "rendered" ? result.svg : null; + useLayoutEffect(() => { + const svg = renderedSvg === null ? null : containerRef.current?.querySelector("svg"); + if (onRendered && svg) onRendered(svg); + }, [onRendered, renderedSvg]); if (result.status === "error") { return ( @@ -203,13 +216,23 @@ export function MermaidDiagram({ ); } + if (mode.onRendered) { + return ( +
+ ); + } + return (
diff --git a/apps/web/src/components/featureMap/FeatureMapPanel.tsx b/apps/web/src/components/featureMap/FeatureMapPanel.tsx new file mode 100644 index 0000000000..1875747819 --- /dev/null +++ b/apps/web/src/components/featureMap/FeatureMapPanel.tsx @@ -0,0 +1,588 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { + EnvironmentId, + FeatureMapRef, + FeatureMapSnapshot, + ProjectId, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { BotIcon, RefreshCwIcon } from "lucide-react"; +import { Suspense, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; + +import { useOpenLink } from "~/browser/useOpenLink"; +import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; +import { useEnvironments } from "~/state/environments"; +import { useFeatureMap } from "~/state/featureMap"; +import { buildThreadRouteParams } from "~/threadRoutes"; +import { MermaidDiagram } from "../chat/MermaidDiagram"; +import { environmentIdsWithCapability } from "../issues/issueList.logic"; +import type { IssueRowThread } from "../issues/issueStatus.logic"; +import { useIssueRowThreads } from "../issues/useIssueRowThreads"; +import { useOpenIssueOrPullRequestLink } from "../issues/useOpenIssueOrPullRequestLink"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { ScrollArea } from "../ui/scroll-area"; +import { Spinner } from "../ui/spinner"; +import { + featureMapClassOf, + featureMapDiagramSource, + featureMapGaps, + featureMapPartIdOf, + type FeatureMapPartView, + featureMapShown, + foldFeatureMap, +} from "./featureMap.logic"; + +/** How often a visible Map rereads GitHub. */ +const FEATURE_MAP_REFRESH_MS = 60_000; + +type MapSnapshot = Extract; + +const STATE_LABELS = { + planned: "Planned", + working: "Working", + broken: "Broken", + done: "Done", + cancelled: "Cancelled", + unavailable: "Unavailable", + unknown: "Unknown", + incomplete: "Incomplete", + untracked: "Untracked", + context: "Context", +} as const; + +const labelOf = (view: Pick) => + STATE_LABELS[view.state ?? view.note ?? "unknown"]; + +const refLabel = (ref: FeatureMapRef, repository: string) => + `${ref.repository.toLowerCase() === repository.toLowerCase() ? "" : ref.repository}${ + ref.kind === "pull" ? " PR " : "" + }#${ref.number}`.trim(); + +const timeOf = (iso: string) => + new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + +/** + * Fork: the thread's project as its feature map (toolboxmd/chromeria#235): `docs/map.md` drawn + * with each part coloured by what GitHub proves, a keyboard-reachable part list, and what the map + * does not track. Reread every minute while shown and whenever the window regains focus. + */ +export function FeatureMapPanel({ + environmentId, + projectId, + threadRef, + supported, + theme, +}: { + environmentId: EnvironmentId; + projectId: ProjectId | null; + threadRef: ScopedThreadRef; + /** The server answers `featureMap.read`; an older one leaves the map unknown. */ + supported: boolean; + theme: "light" | "dark"; +}) { + const query = useFeatureMap(environmentId, supported ? projectId : null); + const { refresh } = query; + // The interval lives with the mounted tab, so it stops when the tab closes. + useEffect(() => { + if (!supported || projectId === null) return; + const interval = window.setInterval(refresh, FEATURE_MAP_REFRESH_MS); + window.addEventListener("focus", refresh); + return () => { + window.clearInterval(interval); + window.removeEventListener("focus", refresh); + }; + }, [projectId, refresh, supported]); + + const shown = featureMapShown({ + supported, + latest: + query.error !== null + ? { _tag: "failure", message: query.error } + : query.isSuccess && query.data !== null + ? { _tag: "success", snapshot: query.data } + : null, + lastGood: query.data, + }); + + const header = ( +
+ + {shown.kind === "snapshot" + ? `Map from ${shown.snapshot.branch} at ${shown.snapshot.commit.slice(0, 7)}` + : "Map"} + {shown.kind === "snapshot" && shown.stale !== null + ? ` · as of ${timeOf(shown.stale.since)}, GitHub unreachable` + : null} + + +
+ ); + + let body; + if (projectId === null) { + body = This thread has no project.; + } else if (shown.kind === "unsupported") { + body = Unknown: this server cannot read a feature map.; + } else if (shown.kind === "loading") { + body = ( + + + + ); + } else if (shown.kind === "error") { + body = {shown.message}; + } else if (shown.snapshot._tag === "NoMap") { + body = ( + + {shown.snapshot.reason === "missing" + ? `No docs/map.md on ${shown.snapshot.branch}.` + : `docs/map.md on ${shown.snapshot.branch} has no mermaid block.`} + + ); + } else { + body = ; + } + + return ( +
+ {header} + {body} +
+ ); +} + +function Message({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function FeatureMapBody({ + map, + threadRef, + theme, +}: { + map: MapSnapshot; + threadRef: ScopedThreadRef; + theme: "light" | "dark"; +}) { + const navigate = useNavigate(); + const { environments } = useEnvironments(); + const linkEnvironments = useMemo( + () => new Set(environmentIdsWithCapability(environments, "issueLinks")), + [environments], + ); + // Linked threads are read for the map's Issue refs, from every server that keeps links. + const issueEntries = useMemo( + () => + map.facts.flatMap((entry) => + entry.fact?.kind === "issue" + ? [ + { + host: map.host, + repository: entry.repository, + number: entry.number, + closingPullRequests: entry.fact.openClosingPullRequests, + }, + ] + : [], + ), + [map], + ); + const { + threadsByIssue, + working, + error: threadsError, + } = useIssueRowThreads(issueEntries, linkEnvironments); + const views = useMemo( + () => foldFeatureMap(map, { threadsByIssue, working }), + [map, threadsByIssue, working], + ); + const gaps = useMemo(() => featureMapGaps(map), [map]); + const source = useMemo(() => featureMapDiagramSource(map.source, views), [map.source, views]); + + const openLink = useOpenLink(threadRef); + const openChangeRequestLink = useOpenChangeRequestLink(threadRef); + const openIssueOrPullRequest = useOpenIssueOrPullRequestLink( + openChangeRequestLink, + openLink, + threadRef, + ); + const openUrl = useCallback( + (url: string) => { + if (openIssueOrPullRequest(url, threadRef.environmentId) === null) void openLink(url); + }, + [openIssueOrPullRequest, openLink, threadRef.environmentId], + ); + const openThread = useCallback( + (thread: IssueRowThread) => + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }), + [navigate], + ); + + return ( + +
+ {threadsError !== null ? ( +

+ Some servers could not say which threads are working: {threadsError} +

+ ) : null} + + + + } + > + + + + +
+
+ ); +} + +interface IconPlacement { + readonly id: string; + readonly left: number; + readonly top: number; +} + +/** + * The drawing itself. Parts open their primary ref on click; a part some thread is working on + * carries an app-drawn icon at its top right, placed from the rendered node's box. + */ +function FeatureMapDiagram({ + source, + theme, + views, + onOpen, + onOpenThread, +}: { + source: string; + theme: "light" | "dark"; + views: ReadonlyArray; + onOpen: (url: string) => void; + onOpenThread: (thread: IssueRowThread) => void; +}) { + const wrapperRef = useRef(null); + const [svg, setSvg] = useState(null); + const [icons, setIcons] = useState>([]); + const byId = useMemo(() => new Map(views.map((view) => [view.part.id, view])), [views]); + // Node clicks read the newest views, so a refresh never needs the SVG rebound. + const openNode = useEffectEvent((elementId: string) => { + const id = featureMapPartIdOf(elementId, new Set(byId.keys())); + const url = id === null ? null : (byId.get(id)?.url ?? null); + if (url !== null) onOpen(url); + }); + useEffect(() => { + if (svg === null) return; + const nodes = [...svg.querySelectorAll("g.node")]; + const onClick = (event: MouseEvent) => openNode((event.currentTarget as SVGGElement).id); + for (const node of nodes) { + node.style.cursor = "pointer"; + node.addEventListener("click", onClick); + } + return () => { + for (const node of nodes) node.removeEventListener("click", onClick); + }; + }, [svg]); + + const workingIds = useMemo( + () => views.filter((view) => view.threads.length > 0).map((view) => view.part.id), + [views], + ); + useEffect(() => { + const wrapper = wrapperRef.current; + if (svg === null || wrapper === null) return; + const place = () => { + const origin = wrapper.getBoundingClientRect(); + const wanted = new Set(workingIds); + const placements: Array = []; + for (const node of svg.querySelectorAll("g.node")) { + const id = featureMapPartIdOf(node.id, wanted); + if (id === null) continue; + const box = node.getBoundingClientRect(); + placements.push({ id, left: box.right - origin.left, top: box.top - origin.top }); + } + setIcons(placements); + }; + place(); + const observer = new ResizeObserver(place); + observer.observe(wrapper); + return () => observer.disconnect(); + }, [svg, workingIds]); + + return ( +
+ + {icons.map((icon) => { + const view = byId.get(icon.id); + if (view === undefined) return null; + return ( +
+ +
+ ); + })} +
+ ); +} + +/** One working thread opens directly; several open a menu of their titles. */ +function ThreadIcon({ + threads, + partName, + onOpen, +}: { + threads: ReadonlyArray; + partName: string; + onOpen: (thread: IssueRowThread) => void; +}) { + const only = threads.length === 1 ? threads[0]! : null; + const label = + only === null + ? `${threads.length} threads working on ${partName}` + : `Open thread working on ${partName}: ${only.title}`; + if (only !== null) { + return ( + + ); + } + return ( + + + + {threads.length} + + } + /> + + {threads.map((thread) => ( + onOpen(thread)}> + {thread.title} + + ))} + + + ); +} + +/** Every part in words, with the same links as the drawing, reachable by keyboard. */ +function PartList({ + views, + repository, + onOpen, + onOpenThread, +}: { + views: ReadonlyArray; + repository: string; + onOpen: (url: string) => void; + onOpenThread: (thread: IssueRowThread) => void; +}) { + return ( +
+

Parts

+
    + {views.map((view) => { + const [primary, ...secondary] = view.part.refs; + return ( +
  • + + {view.url === null || primary === undefined ? ( + {view.part.name} + ) : ( + + )} + {secondary.map((ref) => ( + + ))} + {view.threads.length > 0 ? ( + + ) : null} +
  • + ); + })} +
+
+ ); +} + +function StateSwatch({ view }: { view: FeatureMapPartView }) { + const label = labelOf(view); + // The swatch reuses the diagram's own fill and stroke, so list and drawing agree. + const style = Object.fromEntries( + featureMapClassOf(view) + .style.split(",") + .map((declaration) => declaration.split(":") as [string, string]), + ); + return ( + + + {label} + + ); +} + +function GapList({ + gaps, + repository, + onOpen, +}: { + gaps: ReturnType; + repository: string; + onOpen: (url: string) => void; +}) { + const rows: Array<{ + title: string; + items: ReadonlyArray<{ readonly key: string; readonly node: React.ReactNode }>; + }> = [ + { + title: "Untracked parts", + items: gaps.untrackedParts.map((part) => ({ key: part.id, node: part.name })), + }, + { + title: "Shared Issues", + items: gaps.sharedIssues.map(({ ref, partIds }) => ({ + key: `${ref.repository}#${ref.number}`, + node: ( + + onOpen(ref.url)}>{refLabel(ref, repository)} on{" "} + {partIds.join(", ")} + + ), + })), + }, + { + title: gaps.openIssuesPartial ? "Open Issues on no part (partial)" : "Open Issues on no part", + items: gaps.openIssuesOnNoPart.map((issue) => ({ + key: String(issue.number), + node: ( + onOpen(issue.url)}> + #{issue.number} {issue.title} + + ), + })), + }, + { + title: "Cancelled, unavailable and incomplete refs", + items: gaps.problemRefs.map(({ partId, ref, note }) => ({ + key: `${partId} ${ref.repository}#${ref.number}`, + node: ( + + {partId}:{" "} + onOpen(ref.url)}>{refLabel(ref, repository)}{" "} + {note} + + ), + })), + }, + { + title: "Ref lines for undeclared IDs", + items: gaps.undeclaredRefLines.map((line, index) => ({ + key: String(index), + node: {`%% ${line.id}: ${line.text}`}, + })), + }, + { + title: "Unreadable refs", + items: gaps.unreadableRefs.map((problem, index) => ({ + key: String(index), + node: ( + + {problem.id}: {problem.text} + + ), + })), + }, + ]; + return ( +
+

Gaps

+ {rows.map((row) => ( +
+
+ {row.title} ({row.items.length}) +
+ {row.items.length === 0 ? null : ( +
    + {row.items.map((item) => ( +
  • {item.node}
  • + ))} +
+ )} +
+ ))} +
+ ); +} + +function LinkButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} diff --git a/apps/web/src/components/featureMap/featureMap.logic.test.ts b/apps/web/src/components/featureMap/featureMap.logic.test.ts new file mode 100644 index 0000000000..b58ed906a1 --- /dev/null +++ b/apps/web/src/components/featureMap/featureMap.logic.test.ts @@ -0,0 +1,469 @@ +import { + EnvironmentId, + type FeatureMapIssueFact, + type FeatureMapPart, + type FeatureMapPullRequestFact, + type FeatureMapRef, + type FeatureMapRefFact, + type FeatureMapSnapshot, + ThreadId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { IssueRowThread } from "../issues/issueStatus.logic"; +import { + featureMapDiagramSource, + featureMapGaps, + featureMapPartIdOf, + featureMapShown, + foldFeatureMap, +} from "./featureMap.logic"; + +type MapSnapshot = Extract; + +const REPO = "toolboxmd/contextmd"; +const issue = (number: number, repository = REPO): FeatureMapRef => ({ + kind: "issue", + repository, + number, + url: `https://github.com/${repository}/issues/${number}`, +}); +const pull = (number: number, repository = REPO): FeatureMapRef => ({ + kind: "pull", + repository, + number, + url: `https://github.com/${repository}/pull/${number}`, +}); +const part = (id: string, refs: ReadonlyArray, context = false): FeatureMapPart => ({ + id, + name: `Part ${id}`, + context, + primaryUnreadable: false, + refs, +}); +const issueFact = ( + number: number, + overrides: Partial = {}, + repository = REPO, +): FeatureMapRefFact => ({ + repository, + number, + fact: { + kind: "issue", + url: `https://github.com/${repository}/issues/${number}`, + state: "open", + labels: [], + openClosingPullRequests: [], + complete: true, + ...overrides, + }, +}); +const pullFact = ( + number: number, + overrides: Partial = {}, + repository = REPO, +): FeatureMapRefFact => ({ + repository, + number, + fact: { + kind: "pull", + url: `https://github.com/${repository}/pull/${number}`, + state: "open", + isDraft: false, + mergedIntoDefault: false, + checks: null, + ...overrides, + }, +}); + +const snapshot = ( + parts: ReadonlyArray, + facts: ReadonlyArray, + extra: Partial = {}, +): MapSnapshot => ({ + _tag: "Map", + host: "github.com", + repository: REPO, + branch: "main", + commit: "3f2a1c0", + fetchedAt: "2026-10-13T15:42:00.000Z", + source: "flowchart TD", + parts, + undeclaredRefLines: [], + unreadableRefs: [], + facts, + openIssues: [], + openIssuesPartial: false, + ...extra, +}); + +const environmentId = EnvironmentId.make("rocky"); +const thread = (id: string, pullRequests: ReadonlyArray = []): IssueRowThread => ({ + environmentId, + id: ThreadId.make(id), + title: `Thread ${id}`, + archivedAt: null, + sources: ["manual"], + pullRequests: pullRequests.map((number) => ({ host: "github.com", repository: REPO, number })), +}); +const threadKey = (id: string) => `${environmentId}:${id}`; +const noThreads = { threadsByIssue: new Map(), working: new Set() }; + +const stateOf = (map: MapSnapshot, id: string, threads = noThreads) => { + const view = foldFeatureMap(map, threads).find((entry) => entry.part.id === id)!; + return { state: view.state, note: view.note }; +}; + +describe("foldFeatureMap: an Issue primary", () => { + const map = (fact: FeatureMapRefFact) => snapshot([part("C", [issue(7)])], [fact]); + + it("is planned while open with no closing pull request and no working thread", () => { + expect(stateOf(map(issueFact(7)), "C")).toEqual({ state: "planned", note: null }); + }); + + it("is working while an open pull request would close it", () => { + const fact = issueFact(7, { + openClosingPullRequests: [ + { repository: REPO, number: 22, url: "u22", isDraft: true, checks: null }, + ], + }); + expect(stateOf(map(fact), "C")).toEqual({ state: "working", note: null }); + }); + + it("is working while a linked thread works on it, and planned once it stops", () => { + const threads = new Map([["github.com toolboxmd/contextmd#7", [thread("t1")]]]); + expect( + stateOf(map(issueFact(7)), "C", { + threadsByIssue: threads, + working: new Set([threadKey("t1")]), + }), + ).toEqual({ state: "working", note: null }); + expect( + stateOf(map(issueFact(7)), "C", { threadsByIssue: threads, working: new Set() }), + ).toEqual({ state: "planned", note: null }); + }); + + it("is done once closed as completed, and planned again when reopened", () => { + expect(stateOf(map(issueFact(7, { state: "done" })), "C")).toEqual({ + state: "done", + note: null, + }); + expect(stateOf(map(issueFact(7, { state: "open" })), "C")).toEqual({ + state: "planned", + note: null, + }); + }); + + it("shows cancelled, not done, when closed as not planned", () => { + expect(stateOf(map(issueFact(7, { state: "not-planned" })), "C")).toEqual({ + state: null, + note: "cancelled", + }); + }); + + it("shows unavailable, never a known state, when GitHub returned nothing", () => { + expect(stateOf(map({ repository: REPO, number: 7, fact: null }), "C")).toEqual({ + state: null, + note: "unavailable", + }); + }); + + it("shows unknown when the snapshot has no fact for the ref yet", () => { + expect(stateOf(snapshot([part("C", [issue(7)])], []), "C")).toEqual({ + state: null, + note: "unknown", + }); + }); +}); + +describe("foldFeatureMap: a pull request primary", () => { + it("is done when merged into the default branch, even while a later Issue ref stays open", () => { + const map = snapshot( + [part("SR", [pull(38), issue(15)])], + [pullFact(38, { state: "merged", mergedIntoDefault: true }), issueFact(15)], + ); + expect(stateOf(map, "SR")).toEqual({ state: "done", note: null }); + }); + + it("is working when merged into a branch other than the default", () => { + const map = snapshot( + [part("W", [pull(44)])], + [pullFact(44, { state: "merged", mergedIntoDefault: false })], + ); + expect(stateOf(map, "W")).toEqual({ state: "working", note: null }); + }); + + it("is working while open, and cancelled when closed unmerged", () => { + expect(stateOf(snapshot([part("W", [pull(44)])], [pullFact(44)]), "W").state).toBe("working"); + expect( + stateOf(snapshot([part("W", [pull(44)])], [pullFact(44, { state: "closed" })]), "W"), + ).toEqual({ state: null, note: "cancelled" }); + }); +}); + +describe("foldFeatureMap: broken", () => { + it("wins when any open Issue among the refs carries the bug label", () => { + const map = snapshot( + [part("SR", [pull(38), issue(50)])], + [ + pullFact(38, { state: "merged", mergedIntoDefault: true }), + issueFact(50, { labels: ["Bug"] }), + ], + ); + expect(stateOf(map, "SR").state).toBe("broken"); + }); + + it("ignores the bug label on a closed Issue", () => { + const map = snapshot( + [part("SR", [pull(38), issue(50)])], + [ + pullFact(38, { state: "merged", mergedIntoDefault: true }), + issueFact(50, { state: "done", labels: ["bug"] }), + ], + ); + expect(stateOf(map, "SR").state).toBe("done"); + }); + + it("wins when an open pull request of the part fails its checks, but not a merged one", () => { + const failingClosing = issueFact(7, { + openClosingPullRequests: [ + { repository: REPO, number: 22, url: "u22", isDraft: false, checks: "failing" }, + ], + }); + expect(stateOf(snapshot([part("C", [issue(7)])], [failingClosing]), "C").state).toBe("broken"); + expect( + stateOf(snapshot([part("C", [pull(9)])], [pullFact(9, { checks: "failing" })]), "C").state, + ).toBe("broken"); + expect( + stateOf( + snapshot( + [part("C", [pull(9)])], + [pullFact(9, { state: "merged", mergedIntoDefault: true, checks: "failing" })], + ), + "C", + ).state, + ).toBe("done"); + }); + + it("stays broken while a thread works on the part, and keeps the thread for the icon", () => { + const map = snapshot([part("C", [issue(7)])], [issueFact(7, { labels: ["bug"] })]); + const threads = new Map([["github.com toolboxmd/contextmd#7", [thread("fixer")]]]); + const [view] = foldFeatureMap(map, { + threadsByIssue: threads, + working: new Set([threadKey("fixer")]), + }); + expect(view?.state).toBe("broken"); + expect(view?.threads.map((entry) => entry.id)).toEqual(["fixer"]); + }); +}); + +describe("foldFeatureMap: incomplete or unreadable evidence", () => { + it("does not claim done when an open Issue ref has more labels than GitHub returned", () => { + // The `bug` label could be the 21st label: the part may be broken. + const map = snapshot( + [part("SR", [pull(38), issue(50)])], + [ + pullFact(38, { state: "merged", mergedIntoDefault: true }), + issueFact(50, { complete: false }), + ], + ); + expect(stateOf(map, "SR")).toEqual({ state: null, note: "incomplete" }); + }); + + it("does not claim working when the primary Issue has more closing PRs than GitHub returned", () => { + const map = snapshot( + [part("C", [issue(7)])], + [ + issueFact(7, { + complete: false, + openClosingPullRequests: [ + { repository: REPO, number: 22, url: "u22", isDraft: false, checks: "passing" }, + ], + }), + ], + ); + expect(stateOf(map, "C")).toEqual({ state: null, note: "incomplete" }); + }); + + it("still shows broken when the evidence that was read proves it", () => { + const map = snapshot( + [part("C", [issue(7)])], + [issueFact(7, { complete: false, labels: ["bug"] })], + ); + expect(stateOf(map, "C").state).toBe("broken"); + }); + + it("ignores incomplete evidence on a closed Issue", () => { + const map = snapshot( + [part("C", [issue(7)])], + [issueFact(7, { state: "done", complete: false })], + ); + expect(stateOf(map, "C").state).toBe("done"); + }); + + it("shows unknown, and opens nothing, when the primary ref was unreadable", () => { + const map = snapshot( + [{ ...part("A", [pull(38)]), primaryUnreadable: true }], + [pullFact(38, { state: "merged", mergedIntoDefault: true })], + ); + const [view] = foldFeatureMap(map, noThreads); + expect({ state: view?.state, note: view?.note, url: view?.url }).toEqual({ + state: null, + note: "unknown", + url: null, + }); + }); +}); + +describe("foldFeatureMap: the thread icon", () => { + it("lights only the part whose pull request the working thread carries", () => { + // SR, SC and LJ all name #15; only the thread on SC's PR #45 works. + const map = snapshot( + [ + part("SR", [pull(38), issue(15)]), + part("SC", [pull(45), issue(15)]), + part("LJ", [issue(15)]), + ], + [pullFact(38), pullFact(45), issueFact(15)], + ); + const threads = new Map([["github.com toolboxmd/contextmd#15", [thread("card", [45])]]]); + const views = foldFeatureMap(map, { + threadsByIssue: threads, + working: new Set([threadKey("card")]), + }); + expect( + Object.fromEntries(views.map((view) => [view.part.id, view.threads.map((t) => t.id)])), + ).toEqual({ SR: [], SC: ["card"], LJ: [] }); + expect(views.find((view) => view.part.id === "LJ")?.state).toBe("planned"); + }); + + it("keeps a working thread on an Issue primary that also carries an unrelated part's PR", () => { + // B does not name #7, so the thread's old link to B's PR #38 says nothing about A. + const map = snapshot( + [part("A", [issue(7)]), part("B", [pull(38)])], + [issueFact(7), pullFact(38, { state: "merged", mergedIntoDefault: true })], + ); + const threads = new Map([["github.com toolboxmd/contextmd#7", [thread("t7", [38])]]]); + const [a] = foldFeatureMap(map, { + threadsByIssue: threads, + working: new Set([threadKey("t7")]), + }); + expect(a?.state).toBe("working"); + expect(a?.threads.map((entry) => entry.id)).toEqual(["t7"]); + }); + + it("shows no icon for a linked thread that is not working", () => { + const map = snapshot([part("C", [issue(7)])], [issueFact(7)]); + const threads = new Map([["github.com toolboxmd/contextmd#7", [thread("idle")]]]); + expect( + foldFeatureMap(map, { threadsByIssue: threads, working: new Set() })[0]?.threads, + ).toEqual([]); + }); +}); + +describe("featureMapGaps", () => { + it("lists untracked parts, shared Issues, open Issues on no part, problem refs and lines", () => { + const map = snapshot( + [ + part("S", [], true), + part("LJ", []), + part("SR", [pull(38), issue(15)]), + part("SC", [pull(45), issue(15)]), + part("X", [issue(60)]), + part("Y", [pull(70)]), + part("Z", [issue(80)]), + ], + [ + pullFact(38, { state: "merged", mergedIntoDefault: true }), + issueFact(15), + pullFact(45), + issueFact(60, { state: "not-planned" }), + { repository: REPO, number: 70, fact: null }, + issueFact(80, { complete: false }), + ], + { + openIssues: [ + { number: 15, title: "Serving labels", url: "u15" }, + { number: 49, title: "Recurrence lookups", url: "u49" }, + ], + openIssuesPartial: true, + undeclaredRefLines: [{ id: "GHOST", text: "#5" }], + unreadableRefs: [{ id: "X", text: "issue-9" }], + }, + ); + const gaps = featureMapGaps(map); + expect(gaps.untrackedParts.map((entry) => entry.id)).toEqual(["LJ"]); + expect(gaps.sharedIssues).toEqual([{ ref: issue(15), partIds: ["SR", "SC"] }]); + expect(gaps.openIssuesOnNoPart.map((entry) => entry.number)).toEqual([49]); + expect(gaps.openIssuesPartial).toBe(true); + expect(gaps.problemRefs.map((entry) => [entry.partId, entry.ref.number, entry.note])).toEqual([ + ["X", 60, "cancelled"], + ["Y", 70, "unavailable"], + ["Z", 80, "incomplete"], + ]); + expect(gaps.undeclaredRefLines).toEqual([{ id: "GHOST", text: "#5" }]); + expect(gaps.unreadableRefs).toEqual([{ id: "X", text: "issue-9" }]); + }); +}); + +describe("featureMapDiagramSource", () => { + it("appends one class line per shown state and leaves the drawing untouched", () => { + const map = snapshot( + [part("S", [], true), part("C", [issue(7)]), part("D", [issue(8)]), part("E", [issue(9)])], + [issueFact(7), issueFact(8), issueFact(9, { state: "done" })], + { source: 'flowchart TD\n C["Capture"] --> D' }, + ); + const source = featureMapDiagramSource(map.source, foldFeatureMap(map, noThreads)); + expect(source.startsWith('flowchart TD\n C["Capture"] --> D\n')).toBe(true); + expect(source).toContain("\n class C,D fmPlanned"); + expect(source).toContain("\n class E fmDone"); + expect(source).toContain("\n class S fmContext"); + expect(source).toMatch(/\n {4}classDef fmPlanned /); + }); +}); + +describe("featureMapPartIdOf", () => { + const ids = new Set(["C", "SR", "flowchart"]); + it("reads the part from a rendered node id", () => { + expect(featureMapPartIdOf("mermaid-diagram-12-flowchart-SR-3", ids)).toBe("SR"); + expect(featureMapPartIdOf("mermaid-diagram-0-flowchart-flowchart-0", ids)).toBe("flowchart"); + }); + it("ignores ids that are not parts", () => { + expect(featureMapPartIdOf("mermaid-diagram-12-flowchart-GHOST-3", ids)).toBeNull(); + expect(featureMapPartIdOf("L_C_SR_0", ids)).toBeNull(); + }); +}); + +describe("featureMapShown", () => { + const good = snapshot([part("C", [issue(7)])], [issueFact(7)]); + + it("shows unknown, not an empty map, on a server without the map read", () => { + expect(featureMapShown({ supported: false, latest: null, lastGood: null })).toEqual({ + kind: "unsupported", + }); + }); + + it("keeps the last good snapshot with its time while GitHub is unreachable", () => { + expect( + featureMapShown({ + supported: true, + latest: { _tag: "failure", message: "Could not reach GitHub at github.com." }, + lastGood: good, + }), + ).toEqual({ + kind: "snapshot", + snapshot: good, + stale: { since: good.fetchedAt, message: "Could not reach GitHub at github.com." }, + }); + }); + + it("shows the read error when there is no snapshot yet", () => { + expect( + featureMapShown({ + supported: true, + latest: { _tag: "failure", message: "No GitHub repository." }, + lastGood: null, + }), + ).toEqual({ kind: "error", message: "No GitHub repository." }); + }); +}); diff --git a/apps/web/src/components/featureMap/featureMap.logic.ts b/apps/web/src/components/featureMap/featureMap.logic.ts new file mode 100644 index 0000000000..bf3a33dbf2 --- /dev/null +++ b/apps/web/src/components/featureMap/featureMap.logic.ts @@ -0,0 +1,320 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { + FeatureMapPart, + FeatureMapRef, + FeatureMapRefFact, + FeatureMapRefLineProblem, + FeatureMapSnapshot, +} from "@t3tools/contracts"; + +import { issueKey } from "../issues/issueList.logic"; +import type { IssueRowThread } from "../issues/issueStatus.logic"; + +type MapSnapshot = Extract; +type Fact = NonNullable; + +/** The four states GitHub can prove. */ +export type FeatureMapState = "planned" | "working" | "broken" | "done"; +/** Shown in place of a state when the primary ref cannot decide one. */ +export type FeatureMapNote = + | "cancelled" + | "unavailable" + | "unknown" + | "incomplete" + | "untracked" + | "context"; + +export interface FeatureMapPartView { + readonly part: FeatureMapPart; + readonly state: FeatureMapState | null; + readonly note: FeatureMapNote | null; + /** Where clicking the part goes: the primary ref, as GitHub named it when it answered. */ + readonly url: string | null; + /** Linked threads working on the part now, for its icon. */ + readonly threads: ReadonlyArray; +} + +export interface FeatureMapThreads { + /** Linked threads by `issueKey`. */ + readonly threadsByIssue: ReadonlyMap>; + /** Scoped keys of threads working now. */ + readonly working: ReadonlySet; +} + +const refKey = (ref: { readonly repository: string; readonly number: number }) => + `${ref.repository.toLowerCase()}#${ref.number}`; + +/** Each ref's fact; absent means not read yet, null that GitHub answered nothing. */ +function factsByRef(map: MapSnapshot): ReadonlyMap { + return new Map(map.facts.map((entry) => [refKey(entry), entry.fact])); +} + +/** An Issue written as a ref, or a ref GitHub answered as an Issue. */ +const isIssueRef = (ref: FeatureMapRef, fact: Fact | null | undefined) => + fact ? fact.kind === "issue" : ref.kind === "issue"; + +/** + * Whether the part is broken: proven by the evidence read, or possible because an open Issue's + * labels or closing pull requests ran past the page GitHub returned. + */ +function brokenBy( + part: FeatureMapPart, + facts: ReadonlyMap, +): "broken" | "possibly" | "no" { + let possibly = false; + const proven = part.refs.some((ref, index) => { + const fact = facts.get(refKey(ref)); + if (!fact) return false; + if (fact.kind === "pull") return fact.state === "open" && fact.checks === "failing"; + if (fact.state !== "open") return false; + if (!fact.complete) possibly = true; + if (fact.labels.some((label) => label.toLowerCase() === "bug")) return true; + // Only the primary Issue's closing pull requests are the part's. + return ( + index === 0 && + fact.openClosingPullRequests.some((pullRequest) => pullRequest.checks === "failing") + ); + }); + return proven ? "broken" : possibly ? "possibly" : "no"; +} + +function workingThreadsOf( + part: FeatureMapPart, + facts: ReadonlyMap, + host: string, + threads: FeatureMapThreads, + /** By Issue `refKey`: the primary pull requests of the parts that name that Issue. */ + siblingPrimaryPullRequests: ReadonlyMap>, +): ReadonlyArray { + const primary = part.refs[0]; + if (primary === undefined) return []; + const isWorking = (thread: IssueRowThread) => + threads.working.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); + const linkedTo = (ref: FeatureMapRef) => + threads.threadsByIssue.get( + issueKey({ host, repository: ref.repository, number: ref.number }), + ) ?? []; + if (isIssueRef(primary, facts.get(refKey(primary)))) { + // A thread on this Issue that carries the primary pull request of another part naming the same + // Issue works on that part; any other pull request it carries says nothing about this one. + const siblings = siblingPrimaryPullRequests.get(refKey(primary)); + return linkedTo(primary).filter( + (thread) => + isWorking(thread) && + !thread.pullRequests.some((pullRequest) => siblings?.has(refKey(pullRequest)) === true), + ); + } + // A pull request primary: threads on the part's Issues that carry that pull request, so a + // thread on a sibling part's pull request does not light this one. + const seen = new Set(); + return part.refs + .filter((ref) => isIssueRef(ref, facts.get(refKey(ref)))) + .flatMap(linkedTo) + .filter((thread) => { + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if (seen.has(key) || !isWorking(thread)) return false; + seen.add(key); + return thread.pullRequests.some((pullRequest) => refKey(pullRequest) === refKey(primary)); + }); +} + +/** + * Each part's state. The primary ref decides planned, working or done; broken overrides it when + * an open Issue among the refs is labelled `bug` or an open pull request of the part fails its + * checks. Missing evidence is shown as such, never as a known state. + */ +export function foldFeatureMap( + map: MapSnapshot, + threads: FeatureMapThreads, +): ReadonlyArray { + const facts = factsByRef(map); + const siblingPrimaryPullRequests = new Map>(); + for (const part of map.parts) { + const primary = part.refs[0]; + if (primary === undefined || isIssueRef(primary, facts.get(refKey(primary)))) continue; + for (const ref of part.refs.slice(1)) { + if (!isIssueRef(ref, facts.get(refKey(ref)))) continue; + const pullRequests = siblingPrimaryPullRequests.get(refKey(ref)) ?? new Set(); + pullRequests.add(refKey(primary)); + siblingPrimaryPullRequests.set(refKey(ref), pullRequests); + } + } + return map.parts.map((part): FeatureMapPartView => { + const primary = part.refs[0]; + const working = workingThreadsOf(part, facts, map.host, threads, siblingPrimaryPullRequests); + const fact = primary === undefined ? undefined : facts.get(refKey(primary)); + // An unreadable first token leaves no primary: the next ref must not decide or open it. + const url = part.primaryUnreadable ? null : (fact?.url ?? primary?.url ?? null); + if (part.context) return { part, state: null, note: "context", url, threads: working }; + if (primary === undefined) + return { part, state: null, note: "untracked", url, threads: working }; + const base = ((): { state: FeatureMapState | null; note: FeatureMapNote | null } => { + if (part.primaryUnreadable || fact === undefined) return { state: null, note: "unknown" }; + if (fact === null) return { state: null, note: "unavailable" }; + if (fact.kind === "pull") { + if (fact.state === "closed") return { state: null, note: "cancelled" }; + return { state: fact.mergedIntoDefault ? "done" : "working", note: null }; + } + if (fact.state === "not-planned") return { state: null, note: "cancelled" }; + if (fact.state === "done") return { state: "done", note: null }; + return { + state: + fact.openClosingPullRequests.length > 0 || working.length > 0 ? "working" : "planned", + note: null, + }; + })(); + const broken = brokenBy(part, facts); + if (broken === "broken") return { part, state: "broken", note: null, url, threads: working }; + // The unread evidence could hold a defect, so no clean state is claimed. + if (broken === "possibly") { + return { part, state: null, note: "incomplete", url, threads: working }; + } + return { part, ...base, url, threads: working }; + }); +} + +export interface FeatureMapGaps { + readonly untrackedParts: ReadonlyArray; + readonly sharedIssues: ReadonlyArray<{ + readonly ref: FeatureMapRef; + readonly partIds: ReadonlyArray; + }>; + readonly openIssuesOnNoPart: MapSnapshot["openIssues"]; + readonly openIssuesPartial: boolean; + readonly problemRefs: ReadonlyArray<{ + readonly partId: string; + readonly ref: FeatureMapRef; + readonly note: "cancelled" | "unavailable" | "incomplete"; + }>; + readonly undeclaredRefLines: ReadonlyArray; + readonly unreadableRefs: ReadonlyArray; +} + +/** What the map does not track, each row named for what it proves. */ +export function featureMapGaps(map: MapSnapshot): FeatureMapGaps { + const facts = factsByRef(map); + const issueParts = new Map }>(); + const named = new Set(); + const problemRefs: Array = []; + for (const part of map.parts) { + for (const ref of part.refs) { + const key = refKey(ref); + named.add(key); + const fact = facts.get(key); + if (fact === null) problemRefs.push({ partId: part.id, ref, note: "unavailable" }); + else if ( + (fact?.kind === "issue" && fact.state === "not-planned") || + (fact?.kind === "pull" && fact.state === "closed") + ) { + problemRefs.push({ partId: part.id, ref, note: "cancelled" }); + } else if (fact?.kind === "issue" && fact.state === "open" && !fact.complete) { + problemRefs.push({ partId: part.id, ref, note: "incomplete" }); + } + if (!isIssueRef(ref, fact)) continue; + const entry = issueParts.get(key) ?? { ref, partIds: [] }; + if (!entry.partIds.includes(part.id)) entry.partIds.push(part.id); + issueParts.set(key, entry); + } + } + return { + untrackedParts: map.parts.filter((part) => !part.context && part.refs.length === 0), + sharedIssues: [...issueParts.values()].filter((entry) => entry.partIds.length > 1), + openIssuesOnNoPart: map.openIssues.filter( + (openIssue) => !named.has(refKey({ repository: map.repository, number: openIssue.number })), + ), + openIssuesPartial: map.openIssuesPartial, + problemRefs, + undeclaredRefLines: map.undeclaredRefLines, + unreadableRefs: map.unreadableRefs, + }; +} + +/** Mermaid class names; explicit text colours keep both themes readable. */ +const FEATURE_MAP_CLASSES = { + planned: { name: "fmPlanned", style: "fill:#e0e7ff,stroke:#4f46e5,color:#111827" }, + working: { name: "fmWorking", style: "fill:#fde68a,stroke:#b45309,color:#111827" }, + broken: { name: "fmBroken", style: "fill:#fecaca,stroke:#b91c1c,color:#111827" }, + done: { name: "fmDone", style: "fill:#bbf7d0,stroke:#15803d,color:#111827" }, + muted: { + name: "fmMuted", + style: "fill:#f3f4f6,stroke:#9ca3af,color:#4b5563,stroke-dasharray:4 3", + }, + context: { name: "fmContext", style: "fill:#ffffff,stroke:#9ca3af,color:#4b5563" }, +} as const; + +export function featureMapClassOf(view: Pick) { + if (view.state !== null) return FEATURE_MAP_CLASSES[view.state]; + return view.note === "context" ? FEATURE_MAP_CLASSES.context : FEATURE_MAP_CLASSES.muted; +} + +/** + * The map's own drawing with `classDef` and `class` lines appended for each part's state. Labels + * stay as the file wrote them, so no GitHub text enters the diagram. + */ +export function featureMapDiagramSource( + source: string, + views: ReadonlyArray, +): string { + const byClass = new Map>(); + for (const view of views) { + const { name } = featureMapClassOf(view); + byClass.set(name, [...(byClass.get(name) ?? []), view.part.id]); + } + const lines = Object.values(FEATURE_MAP_CLASSES).flatMap(({ name, style }) => { + const ids = byClass.get(name); + return ids === undefined + ? [] + : [` classDef ${name} ${style}`, ` class ${ids.join(",")} ${name}`]; + }); + return `${source.trimEnd()}\n${lines.join("\n")}\n`; +} + +// Mermaid 11 names a flowchart node `-flowchart--`. +const RENDERED_NODE_ID = /^mermaid-diagram-\d+-flowchart-(.+)-\d+$/; + +/** The part a rendered node draws, or null for anything else in the SVG. */ +export function featureMapPartIdOf(elementId: string, partIds: ReadonlySet) { + const id = RENDERED_NODE_ID.exec(elementId)?.[1]; + return id !== undefined && partIds.has(id) ? id : null; +} + +export type FeatureMapShown = + | { readonly kind: "unsupported" } + | { readonly kind: "loading" } + | { readonly kind: "error"; readonly message: string } + | { + readonly kind: "snapshot"; + readonly snapshot: FeatureMapSnapshot; + /** The latest read failed; this is the last good one. */ + readonly stale: { readonly since: string; readonly message: string } | null; + }; + +/** + * What the tab shows: a server without the read is unknown, a failed read keeps the last good + * snapshot with its time, and only with none at all is the error shown. + */ +export function featureMapShown(input: { + readonly supported: boolean; + readonly latest: + | { readonly _tag: "success"; readonly snapshot: FeatureMapSnapshot } + | { readonly _tag: "failure"; readonly message: string } + | null; + readonly lastGood: FeatureMapSnapshot | null; +}): FeatureMapShown { + if (!input.supported) return { kind: "unsupported" }; + const { latest, lastGood } = input; + if (latest?._tag === "success") + return { kind: "snapshot", snapshot: latest.snapshot, stale: null }; + if (latest === null) { + return lastGood === null + ? { kind: "loading" } + : { kind: "snapshot", snapshot: lastGood, stale: null }; + } + return lastGood === null + ? { kind: "error", message: latest.message } + : { + kind: "snapshot", + snapshot: lastGood, + stale: { since: lastGood.fetchedAt, message: latest.message }, + }; +} diff --git a/apps/web/src/components/featureMap/featureMap.mermaid.test.tsx b/apps/web/src/components/featureMap/featureMap.mermaid.test.tsx new file mode 100644 index 0000000000..5ca276465e --- /dev/null +++ b/apps/web/src/components/featureMap/featureMap.mermaid.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom + +import { act, Suspense } from "react"; +import { createRoot } from "react-dom/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test"; + +import { MermaidDiagram } from "../chat/MermaidDiagram"; +import { featureMapPartIdOf } from "./featureMap.logic"; + +// The installed Mermaid, not a mock, renders here; jsdom lacks SVG layout, so sizes are stubbed. +const prototype = window.SVGElement.prototype as SVGElement & { + getBBox?: () => DOMRect; + getComputedTextLength?: () => number; +}; +beforeAll(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + prototype.getBBox = () => ({ x: 0, y: 0, width: 80, height: 24 }) as DOMRect; + prototype.getComputedTextLength = () => 80; +}); +afterAll(() => { + delete prototype.getBBox; + delete prototype.getComputedTextLength; + vi.unstubAllGlobals(); +}); + +async function renderedSvg(source: string): Promise { + const host = document.createElement("div"); + document.body.append(host); + let svg: SVGSVGElement | null = null; + await act(async () => { + createRoot(host).render( + + { + svg = element; + }} + /> + , + ); + }); + await vi.waitFor(() => expect(svg).not.toBeNull()); + return svg!; +} + +describe("the feature map on the pinned Mermaid", () => { + it("finds every part's node by its rendered id, with its state class applied", async () => { + const svg = await renderedSvg(`flowchart TD + C["Capture"] + %% C: #7 + SR["Serve report"] + %% SR: PR #38 #15 + flowchart_2["Node named like the prefix"] + C --> SR --> flowchart_2 + classDef fmDone fill:#bbf7d0 + class SR fmDone`); + const parts = new Set(["C", "SR", "flowchart_2"]); + const nodes = [...svg.querySelectorAll("g.node")].map((node) => ({ + part: featureMapPartIdOf(node.id, parts), + done: node.classList.contains("fmDone"), + })); + expect(nodes).toEqual([ + { part: "C", done: false }, + { part: "SR", done: true }, + { part: "flowchart_2", done: false }, + ]); + // Edges and markers are never read as parts. + const others = [...svg.querySelectorAll("[id]")].filter( + (element) => !element.matches("g.node"), + ); + expect(others.length).toBeGreaterThan(0); + expect(others.map((element) => featureMapPartIdOf(element.id, parts))).toEqual( + others.map(() => null), + ); + }); +}); diff --git a/apps/web/src/components/issues/issueStatus.logic.ts b/apps/web/src/components/issues/issueStatus.logic.ts index c084cd0f89..fd9af52844 100644 --- a/apps/web/src/components/issues/issueStatus.logic.ts +++ b/apps/web/src/components/issues/issueStatus.logic.ts @@ -188,7 +188,9 @@ export function issueStatusInputOf( */ export function issueThreadTargets( entries: ReadonlyArray< - Pick + Pick & { + readonly closingPullRequests: ReadonlyArray>; + } >, linkEnvironments: ReadonlySet, ): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly input: ThreadsForIssuesInput }> { diff --git a/apps/web/src/components/issues/useIssueRowThreads.ts b/apps/web/src/components/issues/useIssueRowThreads.ts index f624194977..60183f22b5 100644 --- a/apps/web/src/components/issues/useIssueRowThreads.ts +++ b/apps/web/src/components/issues/useIssueRowThreads.ts @@ -37,5 +37,6 @@ export function useIssueRowThreads( (): ReadonlySet => new Set(workingKey.length === 0 ? [] : workingKey.split("\n")), [workingKey], ); - return { threadsByIssue, working }; + // The first server that could not answer, so a view can say whose threads it is missing. + return { threadsByIssue, working, error: query.error }; } diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 1c0ece04b7..eb2203053d 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -83,6 +83,33 @@ describe("rightPanelStore", () => { } }); + it("opens the Map as one tab per thread and restores it, dropping kinds this build lacks", () => { + const store = useRightPanelStore.getState(); + store.open(refA, "map"); + store.open(refA, "map"); + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces).toEqual([{ id: "map", kind: "map" }]); + expect(state.activeSurfaceId).toBe("map"); + + const restored = migratePersistedRightPanelState({ + byThreadKey: { + thread: { + isOpen: true, + activeSurfaceId: "map", + surfaces: [ + { id: "map", kind: "map" }, + { id: "timeline", kind: "timeline" }, + ], + }, + }, + }).byThreadKey.thread; + expect(restored).toEqual({ + isOpen: true, + activeSurfaceId: "map", + surfaces: [{ id: "map", kind: "map" }], + }); + }); + it("records single and bulk tab closes, newest first", () => { const store = useRightPanelStore.getState(); const pr = pullRequestSurface({ diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 417137a0a1..5bbf8cd26b 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -31,6 +31,7 @@ const RIGHT_PANEL_KINDS = [ "pull-request", "pull-requests", "issue", + "map", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -91,7 +92,9 @@ export type RightPanelSurface = * Fork: a GitHub Issue opened beside a thread, read through `environmentId`. One tab, so * opening another Issue replaces it. */ - | { id: "issue"; kind: "issue"; environmentId: string; url: string }; + | { id: "issue"; kind: "issue"; environmentId: string; url: string } + /** Fork: the thread's project as its feature map (toolboxmd/chromeria#235). */ + | { id: "map"; kind: "map" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v9 removed the "plan" surface kind (plans render inline in the transcript). @@ -221,6 +224,8 @@ const singletonSurface = ( return { id: "pull-requests", kind }; case "device": return { id: "device", kind }; + case "map": + return { id: "map", kind }; } }; @@ -467,6 +472,10 @@ export function migratePersistedRightPanelState(persistedState: unknown): { // Removed surfaces: plans render inline, agents in thread lineage. const kind = (surface as { kind?: string }).kind; if (kind === "plan" || kind === "agents") return []; + // A kind this build does not know cannot render; drop it rather than show an + // empty tab. + if (!(RIGHT_PANEL_KINDS as ReadonlyArray).includes(kind)) + return []; if (surface.kind === "issue") { if ( surface.id !== "issue" || diff --git a/apps/web/src/state/featureMap.ts b/apps/web/src/state/featureMap.ts new file mode 100644 index 0000000000..f101576447 --- /dev/null +++ b/apps/web/src/state/featureMap.ts @@ -0,0 +1,19 @@ +import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime"; +import { type EnvironmentId, FEATURE_MAP_WS_METHODS, type ProjectId } from "@t3tools/contracts"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { useEnvironmentQuery } from "./query"; + +/** Fork: a project's feature map (toolboxmd/chromeria#235); the open Map tab refreshes it. */ +const featureMapRead = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:feature-map:read", + tag: FEATURE_MAP_WS_METHODS.read, + staleTimeMs: 30_000, +}); + +/** The map read through `environmentId`; data stays the last good snapshot when a read fails. */ +export function useFeatureMap(environmentId: EnvironmentId, projectId: ProjectId | null) { + return useEnvironmentQuery( + projectId === null ? null : featureMapRead({ environmentId, input: { projectId } }), + ); +} diff --git a/docs/fork-features.md b/docs/fork-features.md index 0d32f5b16e..1d1e7066dc 100644 --- a/docs/fork-features.md +++ b/docs/fork-features.md @@ -1254,3 +1254,44 @@ Child creation paths are owned by child-threads and shared with thread-people. "keywords": ["contextmd", "ContextMD", "/contextmd/v1", "contextmd.sqlite", "contextmd-hook"] } ``` + +## feature-map + +```json +{ + "id": "feature-map", + "purpose": "Draw a project's docs/map.md in a right-panel Map tab, each part coloured planned, working, broken or done from GitHub, with working-thread icons and a gaps list.", + "issues": ["https://github.com/toolboxmd/chromeria/issues/235"], + "prs": ["https://github.com/toolboxmd/chromeria/pull/247"], + "newFiles": [ + "apps/server/src/issues/featureMap.test.ts", + "apps/server/src/issues/featureMap.ts", + "apps/web/src/components/featureMap/FeatureMapPanel.tsx", + "apps/web/src/components/featureMap/featureMap.logic.test.ts", + "apps/web/src/components/featureMap/featureMap.logic.ts", + "apps/web/src/components/featureMap/featureMap.mermaid.test.tsx", + "apps/web/src/state/featureMap.ts", + "packages/contracts/src/featureMap.ts" + ], + "upstreamFiles": ["apps/web/src/components/chat/MermaidDiagram.tsx"], + "sharedFiles": [ + "apps/server/src/auth/RpcAuthorization.ts", + "apps/server/src/environment/ServerEnvironment.ts", + "apps/server/src/issues/IssueService.test.ts", + "apps/server/src/issues/IssueService.ts", + "apps/server/src/issues/issueRpcHandlers.ts", + "apps/web/src/components/ChatView.tsx", + "apps/web/src/components/RightPanelTabs.tsx", + "apps/web/src/components/issues/issueStatus.logic.ts", + "apps/web/src/components/issues/useIssueRowThreads.ts", + "apps/web/src/rightPanelStore.test.ts", + "apps/web/src/rightPanelStore.ts", + "docs/user/source-control.md", + "packages/contracts/src/environment.ts", + "packages/contracts/src/index.ts", + "packages/contracts/src/issues.test.ts", + "packages/contracts/src/rpc.ts" + ], + "keywords": ["featureMap", "feature map", "docs/map.md", "primary ref", "context part"] +} +``` diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 36cc7bed8d..61c2c55c2e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -282,3 +282,46 @@ but not change them; give it the permission with a fresh pairing link, as descri To link a thread by hand, open its **Linked PRs and Issues** panel, choose **Link** and enter the Issue's number or URL; the same panel removes a link, and a removed branch link stays removed. Agents can link and unlink their own thread with the `link_issue` and `unlink_issue` MCP tools. + +## Feature map + +The **Map** tab beside a thread draws the thread's project from `docs/map.md` on its GitHub default +branch, and colours each part by what GitHub shows now: planned, working, broken or done. Open it +from the add-tab menu (`A`). The tab rereads GitHub every minute while shown and when the window +regains focus. If GitHub is unreachable, it keeps the last map and shows when it was read. + +The file holds one fenced `mermaid` flowchart. Declare each part once as `ID["Name"]`, and bind it +on its own comment line, which GitHub ignores: + +```text +C["Capture"] +%% C: #7 PR #22 +SR["Serve report"] +%% SR: PR #38 #15 +S["Session"] +%% S: context +``` + +A ref is `#7`, `PR #38`, `owner/repo#231` or `owner/repo PR #227`, on the project's GitHub host. The +first ref is the primary ref, and it alone decides planned, working or done: + +| Primary ref | Planned | Working | Done | +| ----------- | ------------------------------------------- | -------------------------------------------------------- | ------------------------------------ | +| Issue | Open, with no open closing PR and no thread | Open, with an open closing PR or a linked working thread | Closed as completed | +| PR | (never) | Open, or merged into a branch other than the default | Merged into its repository's default | + +A part is broken instead while any open Issue among its refs has the `bug` label, or while an open +PR of the part (a PR ref, or a PR that closes its primary Issue) fails its checks. A merged +component PR does not finish an open Issue: the part stays planned or working until GitHub closes +the Issue. A primary ref closed as not planned, or closed unmerged, shows as cancelled. A ref that +GitHub does not return, because it is private or deleted, shows as unavailable. If an open Issue +has more labels or closing PRs than one GitHub page holds, a part they could break shows as +incomplete instead of a state. A primary ref the map cannot read shows as unknown; the next ref +never takes its place. `context` marks a part another project owns; it is drawn grey and never +counted as missing. + +A part that a thread is working on carries a thread icon; select it to open that thread. Select a +part, or its row in the list under the drawing, to open its primary ref. The gaps list shows parts +with no refs, Issues bound to more than one part, open Issues on no part (marked partial after the +first 500), cancelled, unavailable or incomplete refs, ref lines that name no part, and refs the map +cannot read. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c2735078fb..167ac89e9f 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -179,6 +179,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ issues: Schema.optionalKey(Schema.Boolean), /** Fork: server serves thread ↔ GitHub Issue links (`issueLinks.*`, toolboxmd/t3code#28). */ issueLinks: Schema.optionalKey(Schema.Boolean), + /** Fork: server reads a project's feature map (`featureMap.read`, toolboxmd/chromeria#235). */ + featureMap: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows diff --git a/packages/contracts/src/featureMap.ts b/packages/contracts/src/featureMap.ts new file mode 100644 index 0000000000..3967865984 --- /dev/null +++ b/packages/contracts/src/featureMap.ts @@ -0,0 +1,129 @@ +import * as Schema from "effect/Schema"; +import * as Rpc from "effect/rpc/Rpc"; + +import { EnvironmentAuthorizationError } from "./auth.ts"; +import { IsoDateTime, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { IssueOperationError, IssueState } from "./issues.ts"; + +/** + * Fork: a project's feature map (toolboxmd/chromeria#235). The structure is `docs/map.md` on the + * project's default branch, one Mermaid flowchart whose `%% ID: refs` comment lines bind each part + * to its Issues and pull requests; the facts are those refs as GitHub answers them now. + */ + +export const FeatureMapReadInput = Schema.Struct({ projectId: ProjectId }); +export type FeatureMapReadInput = typeof FeatureMapReadInput.Type; + +/** An Issue or pull request a part names, on the project's GitHub host. */ +export const FeatureMapRef = Schema.Struct({ + kind: Schema.Literals(["issue", "pull"]), + repository: TrimmedNonEmptyString, + number: PositiveInt, + url: TrimmedNonEmptyString, +}); +export type FeatureMapRef = typeof FeatureMapRef.Type; + +/** A declared part. The first ref is the primary ref; a context part is drawn but not tracked. */ +export const FeatureMapPart = Schema.Struct({ + id: TrimmedNonEmptyString, + name: Schema.String, + context: Schema.Boolean, + /** The first token of its ref line could not be read, so no ref may stand in as primary. */ + primaryUnreadable: Schema.Boolean, + refs: Schema.Array(FeatureMapRef), +}); +export type FeatureMapPart = typeof FeatureMapPart.Type; + +/** A ref line that names no declared part, or a ref token the parser could not read. */ +export const FeatureMapRefLineProblem = Schema.Struct({ + id: TrimmedNonEmptyString, + text: Schema.String, +}); +export type FeatureMapRefLineProblem = typeof FeatureMapRefLineProblem.Type; + +/** A head commit's combined check result; null where the head has no checks. */ +export const FeatureMapChecks = Schema.NullOr(Schema.Literals(["passing", "failing", "pending"])); +export type FeatureMapChecks = typeof FeatureMapChecks.Type; + +export const FeatureMapPullRequestFact = Schema.Struct({ + kind: Schema.Literal("pull"), + url: TrimmedNonEmptyString, + state: Schema.Literals(["open", "merged", "closed"]), + isDraft: Schema.Boolean, + /** Merged into its base repository's default branch, not into another branch. */ + mergedIntoDefault: Schema.Boolean, + checks: FeatureMapChecks, +}); +export type FeatureMapPullRequestFact = typeof FeatureMapPullRequestFact.Type; + +export const FeatureMapIssueFact = Schema.Struct({ + kind: Schema.Literal("issue"), + url: TrimmedNonEmptyString, + state: IssueState, + labels: Schema.Array(Schema.String), + /** Every label and closing pull request was read; false when GitHub held more than one page. */ + complete: Schema.Boolean, + /** Open pull requests that close it, with their head checks. */ + openClosingPullRequests: Schema.Array( + Schema.Struct({ + repository: TrimmedNonEmptyString, + number: PositiveInt, + url: TrimmedNonEmptyString, + isDraft: Schema.Boolean, + checks: FeatureMapChecks, + }), + ), +}); +export type FeatureMapIssueFact = typeof FeatureMapIssueFact.Type; + +/** What GitHub answered for one ref; `fact` is null when it answered nothing (private, deleted). */ +export const FeatureMapRefFact = Schema.Struct({ + repository: TrimmedNonEmptyString, + number: PositiveInt, + fact: Schema.NullOr(Schema.Union([FeatureMapIssueFact, FeatureMapPullRequestFact])), +}); +export type FeatureMapRefFact = typeof FeatureMapRefFact.Type; + +const MapLocation = { + host: TrimmedNonEmptyString, + repository: TrimmedNonEmptyString, + branch: TrimmedNonEmptyString, + commit: TrimmedNonEmptyString, + fetchedAt: IsoDateTime, +}; + +/** One coherent read: the map at one commit of the default branch and its refs' facts. */ +export const FeatureMapSnapshot = Schema.Union([ + Schema.TaggedStruct("NoMap", { + ...MapLocation, + /** No `docs/map.md`, or one without a fenced `mermaid` block. */ + reason: Schema.Literals(["missing", "no-diagram"]), + }), + Schema.TaggedStruct("Map", { + ...MapLocation, + /** The flowchart, without its fence. */ + source: Schema.String, + parts: Schema.Array(FeatureMapPart), + undeclaredRefLines: Schema.Array(FeatureMapRefLineProblem), + unreadableRefs: Schema.Array(FeatureMapRefLineProblem), + facts: Schema.Array(FeatureMapRefFact), + openIssues: Schema.Array( + Schema.Struct({ number: PositiveInt, title: Schema.String, url: TrimmedNonEmptyString }), + ), + /** The open-Issue read stopped at its cap, so more may exist. */ + openIssuesPartial: Schema.Boolean, + }), +]); +export type FeatureMapSnapshot = typeof FeatureMapSnapshot.Type; + +export const FEATURE_MAP_WS_METHODS = { + read: "featureMap.read", +} as const; + +export const FeatureMapRpcs = [ + Rpc.make(FEATURE_MAP_WS_METHODS.read, { + payload: FeatureMapReadInput, + success: FeatureMapSnapshot, + error: Schema.Union([IssueOperationError, EnvironmentAuthorizationError]), + }), +] as const; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 67142bce89..7e2262332f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -35,6 +35,7 @@ export * from "./projectClone.ts"; export * from "./pullRequest.ts"; export * from "./issues.ts"; export * from "./issueLinks.ts"; +export * from "./featureMap.ts"; export * from "./orchestrationDispatch.ts"; export * from "./orchestrationProject.ts"; export * from "./orchestrationV2.ts"; diff --git a/packages/contracts/src/issues.test.ts b/packages/contracts/src/issues.test.ts index 7c134b4f89..4c1700a497 100644 --- a/packages/contracts/src/issues.test.ts +++ b/packages/contracts/src/issues.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { AuthSourceControlWriteScope } from "./auth.ts"; import { clientRpcRequiredScopes } from "./clientRpcPermissions.ts"; +import { FEATURE_MAP_WS_METHODS } from "./featureMap.ts"; import { ISSUE_WS_METHODS } from "./issues.ts"; describe("Issue RPC permissions", () => { @@ -15,6 +16,7 @@ describe("Issue RPC permissions", () => { ISSUE_WS_METHODS.issuesList, ISSUE_WS_METHODS.issuesDetail, ISSUE_WS_METHODS.issuesStates, + FEATURE_MAP_WS_METHODS.read, ]) { expect(clientRpcRequiredScopes(method, undefined)).toEqual([]); } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0ea71abe28..8f95f14358 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -28,6 +28,7 @@ import * as RpcGroup from "effect/rpc/RpcGroup"; import * as RpcMiddleware from "effect/rpc/RpcMiddleware"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { IssueRpcs } from "./issues.ts"; +import { FeatureMapRpcs } from "./featureMap.ts"; import { IssueLinksRpcGroup } from "./issueLinks.ts"; import { SpectrumRpcGroup } from "./spectrumRpc.ts"; import { @@ -1992,6 +1993,8 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2SubscribeThreadRpc, // Fork: GitHub Issues (toolboxmd/t3code#27). ...IssueRpcs, + // Fork: feature map (toolboxmd/chromeria#235). + ...FeatureMapRpcs, ) // Fork: Issue links (toolboxmd/t3code#28). .merge(PromachosHomeRpcGroup) diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index 30e6fd3a21..0272d5cd7d 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -195,3 +195,4 @@ apps/server/src/provider/acp/XAiAcpExtension.test.ts apps/server/src/provider/acp/XAiAcpExtension.ts apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +apps/web/src/components/chat/MermaidDiagram.tsx