FN-8215: add GitHub issue comment posting

Enable operators to post upstream GitHub issue comments from the Import Tasks preview.

- Add authenticated GitHub CLI and REST fallback support for creating issue comments.
- Expose a validated API route and inline issue-comment composer with optimistic preview updates.
- Add localized UI copy, documentation, release metadata, and coverage for client, route, and modal behavior.

Files changed:
 .changeset/fn-8215-github-issue-add-comment.md     |  7 ++
 docs/dashboard-guide.md                            |  9 ++-
 packages/dashboard/app/api/legacy.ts               | 13 ++++
 .../dashboard/app/components/GitHubImportModal.css | 34 +++++++++
 .../dashboard/app/components/GitHubImportModal.tsx | 68 +++++++++++++++++-
 .../__tests__/GitHubImportModal.test.tsx           | 62 +++++++++++++++++
 packages/dashboard/src/__tests__/github.test.ts    | 48 +++++++++++++
 .../dashboard/src/__tests__/routes-github.test.ts  | 80 ++++++++++++++++++++++
 packages/dashboard/src/github.ts                   | 49 +++++++++++++
 .../dashboard/src/routes/register-git-github.ts    | 56 +++++++++++++++
 packages/i18n/locales/en/app.json                  |  5 ++
 packages/i18n/locales/es/app.json                  |  5 ++
 packages/i18n/locales/fr/app.json                  |  5 ++
 packages/i18n/locales/ko/app.json                  |  5 ++
 packages/i18n/locales/zh-CN/app.json               |  5 ++
 packages/i18n/locales/zh-TW/app.json               |  5 ++
 packages/i18n/src/resources.d.ts                   |  5 ++
 17 files changed, 459 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-8215

Fusion-Task-Lineage: 0fe6acca-e86d-484f-a97f-5746d32717a1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 10:37:00 -07:00
parent 3b0b9a5661
commit 86c281bc38
17 changed files with 459 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let operators post GitHub issue comments directly from Import Tasks.
category: feature
dev: Adds gh-first and token REST fallback comment posting with optimistic preview updates.

View File

@@ -319,7 +319,14 @@ Use Import Tasks on desktop/tablet:
Expected outcome: the list pane shows matching open issues or pull requests and marks entries that already exist on the board. Use **Hide imported** beside the imported count to remove those unavailable rows from the current Issues, Pull Requests, or GitLab list; turning it off restores the greyed **Imported** rows. After a successful GitHub or GitLab import, the source row is marked **Imported** and made unavailable immediately, without waiting for the board list to refresh.
4. Select an issue or pull request row.
Expected outcome: the full-width candidate list stays visible while its title, source link, body, labels or PR metadata, and import controls open in a draggable and resizable detail window. On mobile, that detail is a full-screen sheet. When selected title/body content is in another language, the detail offers **Translate**, **Show original** / **Show translation**, and **Dismiss**; translation is display-only. A pull request preview also shows its checks; use **Refresh checks** to fetch current GitHub check status and comments without reopening the detail. Each failed check has a **Create fix task** action that creates a new task prefilled with the repository, PR, branches, check status, and check-details link.
5. Select the import action in the detail window. Pull requests use **Resolve feedback**, which creates a task to resolve reviewer feedback and address failed CI checks; issues keep **Import**. Each GitHub issue and pull-request comment also has **Import as task**, which creates a separate resolve-feedback task quoting that comment and linking its source without closing the detail window.
<!--
FNXC:GitHubImportDocs 2026-07-17-12:00:
Import Tasks documentation distinguishes Add comment (an upstream GitHub mutation) from Import as task
(existing-comment conversion), so operators can respond in place without expecting a new Fusion task.
-->
5. In an issue detail, use **Add comment** to write a new upstream GitHub comment. The composer remains available for open and closed issues; posting adds the comment to the preview thread immediately. This is separate from **Import as task**, which creates a Fusion resolve-feedback task from an existing GitHub comment.
Expected outcome: Fusion posts the new comment to GitHub, keeps its inline composer available for another response, and shows a success or retryable error message without leaving Import Tasks.
6. Select the import action in the detail window. Pull requests use **Resolve feedback**, which creates a task to resolve reviewer feedback and address failed CI checks; issues keep **Import**. Each GitHub issue and pull-request comment also has **Import as task**, which creates a separate resolve-feedback task quoting that comment and linking its source without closing the detail window.
Expected outcome: Fusion creates the requested task, preserves GitHub provenance/tracking metadata, and returns the completed PR/issue import to the list while leaving comment imports available for further feedback.
Leaving and returning to **Import Tasks** (for example switching to Board and back) restores the prior context for the current project — provider (GitHub/GitLab), active Issues/PRs tab, label filter, selected repository/remote, GitLab project/group inputs, the **Hide imported** preference, and the previously selected issue/PR — instead of resetting to defaults. When GitLab integration is disabled in Settings, the GitLab provider tab is hidden and any restored GitLab provider preference opens on GitHub instead; saved GitLab URLs and tokens remain configured. The restored selection re-validates against the freshly reloaded list; a selection that no longer exists (e.g. the issue was closed upstream) clears gracefully rather than showing a stuck or empty preview. First-time opens with no prior state keep the existing default-remote auto-detect behavior. State is scoped per project and does not leak across projects.

View File

@@ -1751,6 +1751,19 @@ export async function apiCloseGitHubIssue(repo: string, number: number): Promise
});
}
/*
FNXC:GitHubImport 2026-07-17-12:00:
Posts a new comment to the upstream GitHub issue. This is deliberately separate from
apiImportGitHubComment, which creates a Fusion resolve-feedback task from an existing comment.
*/
export async function apiAddGitHubIssueComment(repo: string, number: number, body: string): Promise<void> {
await api<{ ok: boolean }>("/github/issues/comment", {
method: "POST",
body: JSON.stringify({ repo, number, body }),
});
}
/** Import a specific GitHub pull request as a fn review task */
export function apiImportGitHubPull(owner: string, repo: string, prNumber: number, projectId?: string): Promise<Task> {
return api<Task>(withProjectId("/github/pulls/import", projectId), {

View File

@@ -352,6 +352,29 @@ when the preview is short; the top border separates it from scrolling content.
min-height: 36px;
}
/*
FNXC:GitHubImport 2026-07-17-12:00:
The upstream-comment composer shares the detail action bar but takes a full row, keeping its
textarea and submit control reachable before Import on desktop and the mobile detail sheet.
*/
.github-import-issue-comment-composer {
display: flex;
flex: 1 0 100%;
gap: var(--space-sm);
}
.github-import-issue-comment-composer__input {
flex: 1 1 auto;
min-width: 0;
min-height: 36px;
resize: vertical;
}
.github-import-issue-comment-composer__submit {
align-self: flex-end;
}
/*
FNXC:GitHubImport 2026-07-15-18:20 (superseded 2026-07-15-23:25):
Supersedes #551a2a3c1's note here. That change dropped `justify-content: space-between` and gave the
@@ -1740,6 +1763,17 @@ Import Tasks embedded header now adopts the canonical ViewHeader chrome — edge
padding: 0 var(--space-md);
font-size: 13px;
}
.github-import-issue-comment-composer {
flex-direction: column;
}
.github-import-issue-comment-composer__input,
.github-import-issue-comment-composer__submit {
width: 100%;
min-height: 40px;
}
}
/*

View File

@@ -9,6 +9,7 @@ import {
apiFetchGitHubPullDetail,
apiFetchGitHubIssueDetail,
apiCloseGitHubIssue,
apiAddGitHubIssueComment,
apiImportGitHubPull,
apiImportGitHubComment,
apiFetchGitLabProjectIssues,
@@ -529,6 +530,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
const [closingIssue, setClosingIssue] = useState(false);
const [closeToast, setCloseToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
const closeToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [commentBody, setCommentBody] = useState("");
const [addingComment, setAddingComment] = useState(false);
// FNXC:GitHubImport 2026-07-16-17:00: Track check-task submission by name + row index so duplicate GitHub check names never disable each other's controls.
const [creatingCheckFixTaskRows, setCreatingCheckFixTaskRows] = useState<Set<string>>(new Set());
const [checkFixTaskToast, setCheckFixTaskToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
@@ -1268,6 +1271,45 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
}
}, [selectedIssueNumber, owner, repo, t, confirm]);
/*
FNXC:GitHubImport 2026-07-17-12:00:
This composer posts a NEW upstream GitHub issue comment, rather than importing an existing
comment as a Fusion task. On success it updates both issue-detail surfaces so the shared
CommentsThread remains immediate and cache-first reselection does not lose the posted comment.
*/
const handleAddIssueComment = useCallback(async () => {
const body = commentBody.trim();
if (selectedIssueNumber === null || !owner.trim() || !repo.trim() || !body) return;
const issueNumber = selectedIssueNumber;
const repository = `${owner.trim()}/${repo.trim()}`;
setAddingComment(true);
if (closeToastTimerRef.current) clearTimeout(closeToastTimerRef.current);
setCloseToast(null);
try {
await apiAddGitHubIssueComment(repository, issueNumber, body);
const postedComment: GitHubCommentDetail = {
author: t("git.commentAuthorYou", "You"),
body,
createdAt: new Date().toISOString(),
authorIsBot: false,
};
const cachedDetail = issueDetailCacheRef.current.get(issueNumber) ?? { comments: [] };
issueDetailCacheRef.current.set(issueNumber, {
...cachedDetail,
comments: [...cachedDetail.comments, postedComment],
});
setIssueDetail((current) => current ? { ...current, comments: [...current.comments, postedComment] } : { comments: [postedComment] });
setCommentBody("");
setCloseToast({ type: "success", message: t("git.commentPosted", "Comment posted") });
} catch (err: unknown) {
setCloseToast({ type: "error", message: getErrorMessage(err) });
} finally {
setAddingComment(false);
closeToastTimerRef.current = setTimeout(() => setCloseToast(null), 4000);
}
}, [commentBody, selectedIssueNumber, owner, repo, t]);
const selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
const selectedPull = pulls.find((p) => p.number === selectedPullNumber);
@@ -1891,7 +1933,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
role="status"
data-testid="github-import-issue-close-toast"
>
{closeToast.message}
<span data-testid="github-import-issue-comment-toast">{closeToast.message}</span>
</div>
)}
@@ -2125,6 +2167,30 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
The detail action remains Import for issues, while pull requests are explicitly Resolve feedback so their imported task covers reviewer feedback and failed checks. Close issue remains to its left and is unaffected.
*/}
<div className="github-import-detail-actions" data-testid="github-import-detail-actions">
{activeTab === "issues" && selectedIssue && (
<form
className="github-import-issue-comment-composer"
onSubmit={(event) => { event.preventDefault(); void handleAddIssueComment(); }}
>
<textarea
className="input github-import-issue-comment-composer__input"
data-testid="github-import-issue-comment-input"
value={commentBody}
onChange={(event) => setCommentBody(event.target.value)}
placeholder={t("git.addCommentPlaceholder", "Write a comment…")}
aria-label={t("git.addComment", "Add comment")}
disabled={addingComment}
/>
<button
type="submit"
className="btn btn-primary github-import-issue-comment-composer__submit"
data-testid="github-import-issue-comment-submit"
disabled={!commentBody.trim() || addingComment}
>
{addingComment ? <Loader2 size={14} className="spin" /> : t("git.addComment", "Add comment")}
</button>
</form>
)}
{activeTab === "issues" && selectedIssue && !selectedIssueClosed && (
<button
className="btn btn-danger github-import-issue-close"

View File

@@ -9,6 +9,7 @@ import {
apiFetchGitHubPullDetail,
apiFetchGitHubIssueDetail,
apiCloseGitHubIssue,
apiAddGitHubIssueComment,
apiImportGitHubPull,
apiImportGitHubComment,
apiFetchGitLabProjectIssues,
@@ -38,6 +39,7 @@ vi.mock("../../api", async (importOriginal) => {
apiFetchGitHubPullDetail: vi.fn(),
apiFetchGitHubIssueDetail: vi.fn(),
apiCloseGitHubIssue: vi.fn(),
apiAddGitHubIssueComment: vi.fn(),
apiImportGitHubPull: vi.fn(),
apiImportGitHubComment: vi.fn(),
apiFetchGitLabProjectIssues: vi.fn(),
@@ -189,6 +191,7 @@ describe("GitHubImportModal", () => {
vi.mocked(apiFetchGitHubPullDetail).mockReset();
vi.mocked(apiFetchGitHubIssueDetail).mockReset();
vi.mocked(apiCloseGitHubIssue).mockReset();
vi.mocked(apiAddGitHubIssueComment).mockReset();
vi.mocked(apiImportGitHubPull).mockReset();
vi.mocked(apiImportGitHubComment).mockReset();
vi.mocked(apiFetchGitLabProjectIssues).mockReset();
@@ -207,6 +210,7 @@ describe("GitHubImportModal", () => {
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValue({ comments: [], checks: [] });
vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValue({ comments: [] });
vi.mocked(apiCloseGitHubIssue).mockResolvedValue(undefined);
vi.mocked(apiAddGitHubIssueComment).mockResolvedValue(undefined);
vi.mocked(apiImportGitHubComment).mockResolvedValue(mockTask);
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValue([]);
vi.mocked(apiFetchGitLabGroupIssues).mockResolvedValue([]);
@@ -2725,5 +2729,63 @@ describe("GitHubImportModal — detail actions sit at the bottom (operator repor
expect(screen.queryByTestId("github-import-issue-close")).toBeNull();
});
});
/*
FNXC:GitHubImport 2026-07-17-12:00:
The upstream-comment affordance is one FloatingWindow surface shared by modal and right-dock
presentations. Exercise both so neither presentation silently loses posting or optimistic cache behavior.
*/
it.each(["modal", "embedded"] as const)("posts and optimistically preserves comments in %s presentation", async (presentation) => {
const issues = [
{ number: 18, title: "Commentable Issue", body: "Body", html_url: "https://github.com/dustinbyrne/kb/issues/18", labels: [], state: "closed" as const },
{ number: 19, title: "Other Issue", body: "Other", html_url: "https://github.com/dustinbyrne/kb/issues/19", labels: [], state: "open" as const },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValueOnce({
comments: [{ author: "octocat", body: "Existing comment", createdAt: "2026-07-17T00:00:00.000Z", authorIsBot: false }],
});
vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValueOnce({ comments: [] });
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} presentation={presentation} />);
fireEvent.click(await screen.findByRole("button", { name: /Select issue #18/i }));
const input = await screen.findByTestId("github-import-issue-comment-input");
const submit = screen.getByTestId("github-import-issue-comment-submit");
expect(submit).toBeDisabled();
fireEvent.change(input, { target: { value: "A posted comment" } });
fireEvent.click(submit);
await waitFor(() => {
expect(apiAddGitHubIssueComment).toHaveBeenCalledWith("dustinbyrne/kb", 18, "A posted comment");
expect(input).toHaveValue("");
expect(screen.getByTestId("github-import-issue-comment-toast")).toHaveTextContent("Comment posted");
expect(screen.getByTestId("github-import-issue-comments")).toHaveTextContent("A posted comment");
});
// Switch away and back: a cache-first reselection must retain the optimistic append without refetching.
fireEvent.click(screen.getByRole("button", { name: /Select issue #19/i }));
await waitFor(() => expect(screen.getByTestId("github-import-preview-card")).toHaveTextContent("Other Issue"));
fireEvent.click(screen.getByRole("button", { name: /Select issue #18/i }));
await waitFor(() => expect(screen.getByTestId("github-import-issue-comments")).toHaveTextContent("A posted comment"));
});
it("preserves a failed comment for retry and never renders the composer on pulls", async () => {
vi.mocked(apiAddGitHubIssueComment).mockRejectedValueOnce(new Error("comment failed"));
await openDetail();
const input = await screen.findByTestId("github-import-issue-comment-input");
fireEvent.change(input, { target: { value: "Retry me" } });
fireEvent.click(screen.getByTestId("github-import-issue-comment-submit"));
await waitFor(() => {
expect(screen.getByTestId("github-import-issue-comment-toast")).toHaveTextContent("comment failed");
expect(input).toHaveValue("Retry me");
});
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]);
fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i }));
await waitFor(() => expect(screen.queryByTestId("github-import-issue-comment-input")).toBeNull());
});
});
});

View File

@@ -69,6 +69,54 @@ describe("GitHubClient", () => {
});
});
describe("addIssueComment", () => {
it("posts through gh CLI when authenticated", async () => {
mockRunGhAsync.mockResolvedValue("");
await client.addIssueComment("owner", "repo", 42, "A new comment");
expect(mockRunGhAsync).toHaveBeenCalledWith([
"issue", "comment", "42", "--repo", "owner/repo", "--body", "A new comment",
]);
});
it("falls back to REST when gh is unavailable and a token exists", async () => {
mockIsGhAvailable.mockReturnValue(false);
mockIsGhAuthenticated.mockReturnValue(false);
const tokenClient = new GitHubClient("ghp_token");
const fetchSpy = vi.spyOn(global, "fetch" as any).mockResolvedValue({ ok: true } as any);
await tokenClient.addIssueComment("owner", "repo", 42, "A new comment");
expect(fetchSpy).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/issues/42/comments",
expect.objectContaining({ method: "POST", body: JSON.stringify({ body: "A new comment" }) }),
);
});
it("rejects when neither gh nor a token can authenticate", async () => {
mockIsGhAvailable.mockReturnValue(false);
mockIsGhAuthenticated.mockReturnValue(false);
await expect(new GitHubClient().addIssueComment("owner", "repo", 42, "A new comment")).rejects.toThrow(
"no GITHUB_TOKEN provided",
);
});
it("maps REST 404 responses to a not-found error", async () => {
mockIsGhAvailable.mockReturnValue(false);
mockIsGhAuthenticated.mockReturnValue(false);
const tokenClient = new GitHubClient("ghp_token");
vi.spyOn(global, "fetch" as any).mockResolvedValue({
ok: false, status: 404, statusText: "Not Found", json: async () => ({ message: "Not Found" }),
} as any);
await expect(tokenClient.addIssueComment("owner", "repo", 42, "A new comment")).rejects.toThrow(
"Issue #42 not found in owner/repo",
);
});
});
describe("createIssue", () => {
it("falls back to API when gh path fails and token is configured", async () => {
const clientWithToken = new GitHubClient("ghp_token");

View File

@@ -3710,3 +3710,83 @@ describe("PR conflict refresh + reclaim routes", () => {
expect(mergeSpy).not.toHaveBeenCalled();
});
});
describe("POST /github/issues/comment", () => {
let store: TaskStore;
let originalToken: string | undefined;
beforeEach(() => {
store = createMockStore();
originalToken = process.env.GITHUB_TOKEN;
delete process.env.GITHUB_TOKEN;
mockIsGhAuthenticated.mockReturnValue(true);
vi.spyOn(GitHubClient.prototype, "addIssueComment").mockResolvedValue(undefined);
});
afterEach(() => {
if (originalToken === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = originalToken;
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("posts through an authenticated gh client", async () => {
const commentSpy = vi.spyOn(GitHubClient.prototype, "addIssueComment").mockResolvedValue(undefined);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/comment", JSON.stringify({
repo: "owner/repo", number: 42, body: "Thanks for the report",
}), { "content-type": "application/json" });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(commentSpy).toHaveBeenCalledWith("owner", "repo", 42, "Thanks for the report");
});
it("permits a token-only host when gh is unauthenticated", async () => {
process.env.GITHUB_TOKEN = "ghp_token";
mockIsGhAuthenticated.mockReturnValue(false);
const commentSpy = vi.spyOn(GitHubClient.prototype, "addIssueComment").mockResolvedValue(undefined);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/comment", JSON.stringify({
repo: "owner/repo", number: 42, body: "Thanks for the report",
}), { "content-type": "application/json" });
expect(res.status).toBe(200);
expect(commentSpy).toHaveBeenCalledWith("owner", "repo", 42, "Thanks for the report");
});
it("rejects when neither gh nor a token is available", async () => {
mockIsGhAuthenticated.mockReturnValue(false);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/comment", JSON.stringify({
repo: "owner/repo", number: 42, body: "Thanks for the report",
}), { "content-type": "application/json" });
expect(res.status).toBe(401);
});
it.each([
{ repo: "owner", number: 42, body: "Comment" },
{ repo: "owner/repo", number: 0, body: "Comment" },
{ repo: "owner/repo", number: 42, body: " " },
])("rejects invalid comment payloads", async (body) => {
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/comment", JSON.stringify(body), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
});
it("maps upstream not-found errors", async () => {
vi.spyOn(GitHubClient.prototype, "addIssueComment").mockRejectedValue(new Error("Issue #42 not found"));
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/comment", JSON.stringify({
repo: "owner/repo", number: 42, body: "Comment",
}), { "content-type": "application/json" });
expect(res.status).toBe(404);
});
});

View File

@@ -4062,6 +4062,55 @@ export class GitHubClient {
}
}
/*
FNXC:GitHubImport 2026-07-17-12:00:
Import-preview operators can post a new comment to the upstream issue without leaving Fusion.
Prefer `gh issue comment` and fall back to the authenticated REST endpoint, matching closeIssue
so hosts with either CLI authentication or GITHUB_TOKEN remain supported.
*/
async addIssueComment(owner: string, repo: string, number: number, body: string): Promise<void> {
if (this.hasGhAuth()) {
try {
await runGhAsync([
"issue", "comment", String(number),
"--repo", `${owner}/${repo}`,
"--body", body,
]);
return;
} catch (err) {
if (this.token) {
await this.addIssueCommentWithApi(owner, repo, number, body);
return;
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
await this.addIssueCommentWithApi(owner, repo, number, body);
return;
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' to authenticate.");
}
private async addIssueCommentWithApi(owner: string, repo: string, number: number, body: string): Promise<void> {
const response = await fetch(
`${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}/comments`,
{
method: "POST",
headers: { ...this.buildHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ body }),
}
);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Issue #${number} not found in ${owner}/${repo}`);
}
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
}
}
/**
* Fetch a single pull request by number.
* Uses gh CLI if available, otherwise falls back to REST API.

View File

@@ -4716,6 +4716,62 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
/*
FNXC:GitHubImport 2026-07-17-12:00:
POST /api/github/issues/comment posts a new upstream comment from the Import Tasks issue preview.
Body: { repo: string ("owner/name"), number: number, body: string }. Returns { ok: true }.
*/
router.post("/github/issues/comment", async (req, res) => {
try {
const { repo, number, body } = req.body;
if (!repo || typeof repo !== "string" || !repo.includes("/")) {
throw badRequest("repo is required and must be in 'owner/name' form");
}
if (!number || typeof number !== "number" || number < 1 || !Number.isInteger(number)) {
throw badRequest("number is required and must be a positive number");
}
if (typeof body !== "string" || !body.trim()) {
throw badRequest("body is required and must be a non-empty string");
}
const [owner, repoName] = repo.split("/");
if (!owner || !repoName || repo.split("/").length !== 2) {
throw badRequest("repo must be in 'owner/name' form");
}
const token = process.env.GITHUB_TOKEN?.trim();
/*
FNXC:GitHubImport 2026-07-17-12:00:
Unlike the older close route, token-only hosts are authorized here because isGhAuthenticated
checks only `gh auth status`; rejecting before constructing the client would make its REST
fallback unreachable for GITHUB_TOKEN deployments.
*/
if (!isGhAuthenticated() && !token) {
throw unauthorized("Not authenticated with GitHub. Run `gh auth login` or provide GITHUB_TOKEN.");
}
const client = new GitHubClient(token);
try {
await client.addIssueComment(owner, repoName, number, body.trim());
res.json({ ok: true });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
const errorMessage = err instanceof Error ? err.message : String(err);
if (errorMessage.includes("not found") || errorMessage.includes("404")) {
throw notFound(`Issue not found: ${repo}#${number}`);
}
if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) {
throw unauthorized("Not authenticated with GitHub. Run `gh auth login` or provide GITHUB_TOKEN.");
}
throw new ApiError(502, `GitHub CLI error: ${errorMessage}`);
}
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/**
* POST /api/github/pulls/import
* Import a specific GitHub pull request as a fn review task.

View File

@@ -2477,6 +2477,11 @@
"taskMatches": "Task matches"
},
"git": {
"addComment": "Add comment",
"addCommentPlaceholder": "Write a comment…",
"commentAuthorYou": "You",
"commentPosted": "Comment posted",
"failedToPostComment": "Failed to post comment",
"add": "Add",
"addRemote": "Add Remote",
"advancesHelpFix": "<strong>Fix:</strong> Fusion only shows <em>Sync working tree</em> when at least one advance is genuinely <code>pending</code> and HEAD is not aligned with the integration tip. If entries are already handled (subsumed/orphaned/reachable/superseded), no sync action is offered.",

View File

@@ -2467,6 +2467,11 @@
"taskMatches": "Tareas coincidentes"
},
"git": {
"addComment": "Add comment",
"addCommentPlaceholder": "Write a comment…",
"commentAuthorYou": "You",
"commentPosted": "Comment posted",
"failedToPostComment": "Failed to post comment",
"add": "Añadir",
"addRemote": "Añadir remoto",
"advancesHelpFix": "<strong>Solución:</strong> Fusion solo muestra <em>Sincronizar árbol de trabajo</em> cuando al menos un avance es genuinamente <code>pending</code> y HEAD no está alineado con la punta de integración. Si las entradas ya están gestionadas (subsumed/orphaned/reachable/superseded), no se ofrece ninguna acción de sincronización.",

View File

@@ -2467,6 +2467,11 @@
"taskMatches": "Tâches correspondantes"
},
"git": {
"addComment": "Add comment",
"addCommentPlaceholder": "Write a comment…",
"commentAuthorYou": "You",
"commentPosted": "Comment posted",
"failedToPostComment": "Failed to post comment",
"add": "Ajouter",
"addRemote": "Ajouter un distant",
"advancesHelpFix": "<strong>Correctif :</strong> Fusion n'affiche <em>Synchroniser l'arbre de travail</em> que lorsqu'au moins une avance est réellement <code>pending</code> et que HEAD n'est pas aligné sur la pointe d'intégration. Si les entrées sont déjà traitées (subsumed/orphaned/reachable/superseded), aucune action de synchronisation n'est proposée.",

View File

@@ -2467,6 +2467,11 @@
"taskMatches": "작업 검색 결과"
},
"git": {
"addComment": "Add comment",
"addCommentPlaceholder": "Write a comment…",
"commentAuthorYou": "You",
"commentPosted": "Comment posted",
"failedToPostComment": "Failed to post comment",
"add": "추가",
"addRemote": "원격 추가",
"advancesHelpFix": "<strong>해결 방법:</strong> Fusion은 적어도 하나의 진행 항목이 진정으로 <code>pending</code> 상태이고 HEAD가 통합 끝점과 일치하지 않을 때만 <em>작업 트리 동기화</em>를 표시합니다. 항목이 이미 처리된 경우(subsumed/orphaned/reachable/superseded), 동기화 작업이 제공되지 않습니다.",

View File

@@ -2467,6 +2467,11 @@
"taskMatches": "匹配的任务"
},
"git": {
"addComment": "Add comment",
"addCommentPlaceholder": "Write a comment…",
"commentAuthorYou": "You",
"commentPosted": "Comment posted",
"failedToPostComment": "Failed to post comment",
"add": "添加",
"addRemote": "添加远程",
"advancesHelpFix": "<strong>修复:</strong>Fusion 仅在至少有一个确实为 <code>pending</code> 的推进且 HEAD 未与集成提示对齐时才显示<em>同步工作树</em>。如果条目已被处理(subsumed/orphaned/reachable/superseded),则不提供同步操作。",

View File

@@ -2467,6 +2467,11 @@
"taskMatches": "匹配的任務"
},
"git": {
"addComment": "Add comment",
"addCommentPlaceholder": "Write a comment…",
"commentAuthorYou": "You",
"commentPosted": "Comment posted",
"failedToPostComment": "Failed to post comment",
"add": "新增",
"addRemote": "新增遠端",
"advancesHelpFix": "<strong>修正:</strong>Fusion 僅在至少有一個確實為 <code>pending</code> 的推進且 HEAD 未與整合提示對齊時才顯示<em>同步工作樹</em>。如果條目已被處理(subsumed/orphaned/reachable/superseded),則不提供同步操作。",

View File

@@ -2479,6 +2479,11 @@ export default interface Resources {
"taskMatches": "Task matches"
},
"git": {
"addComment": "Add comment",
"addCommentPlaceholder": "Write a comment…",
"commentAuthorYou": "You",
"commentPosted": "Comment posted",
"failedToPostComment": "Failed to post comment",
"add": "Add",
"addRemote": "Add Remote",
"advancesHelpFix": "<strong>Fix:</strong> Fusion only shows <em>Sync working tree</em> when at least one advance is genuinely <code>pending</code> and HEAD is not aligned with the integration tip. If entries are already handled (subsumed/orphaned/reachable/superseded), no sync action is offered.",