fix(dashboard): honor forced GraphQL transport (#2353)

## Summary
- honor `forceMode` for GraphQL queries and mutations
- keep token-backed Discussion operations on the selected token
transport even when `gh` is authenticated
- add regression coverage for forced-token category lookup, errors, and
Discussion creation

## Test plan
- `CI=true pnpm --filter @fusion/dashboard exec vitest run
src/__tests__/github-discussions.test.ts
src/__tests__/github-forced-mode.test.ts
src/__tests__/github-pr-threads.test.ts`
- `CI=true pnpm build`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* GitHub Discussions GraphQL operations now consistently honor the
selected transport/authentication mode.
* Forced token-based transport correctly applies to discussion queries
and mutations (including discussion creation).
* Missing required authentication is now reported instead of falling
back to an unauthenticated path.
* **Tests**
* Added a test covering discussion creation using forced token-based
GraphQL transport.
* **Release**
  * Shipped as a patch release of the Fusion library.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Phil Larson
2026-07-20 08:49:28 -07:00
committed by GitHub
parent 71c0d0a970
commit c1bc95c1db
3 changed files with 30 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Honor forced GitHub transport selection for GraphQL discussion queries and mutations.
category: fix
dev: Keep Discussion GraphQL operations on the explicitly selected token or gh CLI transport.

View File

@@ -31,6 +31,23 @@ describe("GitHub Discussions GraphQL transport", () => {
.rejects.toThrow("Resource not accessible by personal access token"); .rejects.toThrow("Resource not accessible by personal access token");
}); });
it("creates a discussion through the forced token GraphQL transport", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
data: { repository: { id: "R_1", discussionCategories: { nodes: [{ id: "DC_1" }] } } },
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
data: { createDiscussion: { discussion: { id: "D_1", number: 42, url: "https://github.com/Runfusion/Fusion/discussions/42" } } },
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const discussion = await new GitHubClient({ token: "test", forceMode: "token" })
.createDiscussion("Runfusion", "Fusion", "Title", "Body", "DC_1");
expect(discussion).toEqual({ id: "D_1", number: 42, htmlUrl: "https://github.com/Runfusion/Fusion/discussions/42" });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls.every(([url]) => String(url) === "https://api.github.com/graphql")).toBe(true);
});
it("maps disabled Discussions errors from search to a typed signal", async () => { it("maps disabled Discussions errors from search to a typed signal", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({

View File

@@ -2457,7 +2457,8 @@ export class GitHubClient {
/** Run a read-only GraphQL query (gh CLI when available, else token/REST). */ /** Run a read-only GraphQL query (gh CLI when available, else token/REST). */
private async runGraphqlQuery<T>(query: string, variables: Record<string, string | number | null>): Promise<T | undefined> { private async runGraphqlQuery<T>(query: string, variables: Record<string, string | number | null>): Promise<T | undefined> {
if (this.hasGhAuth()) { if (this.forceMode === "gh-cli" || (this.forceMode === undefined && this.hasGhAuth())) {
if (this.forceMode === "gh-cli") this.requireGh();
const args = ["api", "graphql", "-f", `query=${query}`]; const args = ["api", "graphql", "-f", `query=${query}`];
for (const [key, value] of Object.entries(variables)) { for (const [key, value] of Object.entries(variables)) {
if (value === null) continue; if (value === null) continue;
@@ -2469,6 +2470,7 @@ export class GitHubClient {
if (payload.errors?.length) throw new Error(payload.errors[0].message); if (payload.errors?.length) throw new Error(payload.errors[0].message);
return payload.data; return payload.data;
} }
if (this.forceMode === "token") this.requireToken();
if (this.token) { if (this.token) {
const response = await fetch(`${this.baseUrl}/graphql`, { const response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST", method: "POST",
@@ -2513,7 +2515,8 @@ export class GitHubClient {
} }
private async runGraphqlMutation(query: string, variables: Record<string, string>): Promise<void> { private async runGraphqlMutation(query: string, variables: Record<string, string>): Promise<void> {
if (this.hasGhAuth()) { if (this.forceMode === "gh-cli" || (this.forceMode === undefined && this.hasGhAuth())) {
if (this.forceMode === "gh-cli") this.requireGh();
const args = ["api", "graphql", "-f", `query=${query}`]; const args = ["api", "graphql", "-f", `query=${query}`];
for (const [key, value] of Object.entries(variables)) { for (const [key, value] of Object.entries(variables)) {
args.push("-F", `${key}=${value}`); args.push("-F", `${key}=${value}`);
@@ -2523,6 +2526,7 @@ export class GitHubClient {
if (payload.errors?.length) throw new Error(payload.errors[0].message); if (payload.errors?.length) throw new Error(payload.errors[0].message);
return; return;
} }
if (this.forceMode === "token") this.requireToken();
if (this.token) { if (this.token) {
const response = await fetch(`${this.baseUrl}/graphql`, { const response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST", method: "POST",