FN-8109: import PR comments as resolve feedback tasks
Enable GitHub comment imports to create independent resolve-feedback tasks. - Add comment import controls, status feedback, and client API support - Create validated GitHub comment tasks with quoted feedback and provenance - Label pull request imports as resolve-feedback work and document the workflow Files changed: docs/dashboard-guide.md | 4 +- packages/dashboard/app/api/legacy.ts | 20 ++++++ .../dashboard/app/components/GitHubImportModal.css | 35 +++++++++ .../dashboard/app/components/GitHubImportModal.tsx | 82 +++++++++++++++++++--- .../__tests__/GitHubImportModal.test.tsx | 56 +++++++++++++++ .../dashboard/src/__tests__/routes-github.test.ts | 73 +++++++++++++++++++ .../dashboard/src/routes/register-git-github.ts | 48 ++++++++++++- 7 files changed, 304 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-8109 Fusion-Task-Lineage: bdb4477b-31b4-4e01-8439-b72437ff692d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -313,8 +313,8 @@ 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; 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.
|
||||
Expected outcome: Fusion creates a task (or review task for a pull request), preserves GitHub provenance/tracking metadata, closes the detail window, and returns to the list.
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -2437,6 +2437,26 @@ export function apiImportGitHubPull(owner: string, repo: string, prNumber: numbe
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:GitHubImport 2026-07-16-18:05:
|
||||
* Comment imports preserve the comment payload and issue/PR source context so the server can create a separately auditable resolve-feedback task without closing the detail window.
|
||||
*/
|
||||
export function apiImportGitHubComment(
|
||||
params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
number: number;
|
||||
type: "issue" | "pull";
|
||||
comment: Pick<GitHubCommentDetail, "author" | "body" | "createdAt">;
|
||||
},
|
||||
projectId?: string,
|
||||
): Promise<Task> {
|
||||
return api<Task>(withProjectId("/github/comments/import", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
// --- GitLab Import API ---
|
||||
|
||||
export interface GitLabImportItem {
|
||||
|
||||
@@ -1263,6 +1263,32 @@ Across the thread: a top filter (All/Human/Bot) and prev/next chevrons live in t
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-16-18:15:
|
||||
Per-comment task import stays in the metadata flow so every human and bot comment has a reachable action without displacing author or timestamp context. Result feedback is inline because the detail window remains open for additional feedback imports.
|
||||
*/
|
||||
.github-import-comment__import {
|
||||
margin-left: var(--space-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.github-import-comment__import svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.github-import-comment__result {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.github-import-comment__result--success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.github-import-comment__result--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.github-import-pr-comment--active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
@@ -1631,6 +1657,15 @@ Import Tasks embedded header now adopts the canonical ViewHeader chrome — edge
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* FNXC:GitHubImport 2026-07-16-18:15: Mobile detail sheets keep each comment import action on its own reachable row when metadata wraps. */
|
||||
.github-import-comment__import {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.github-import-comment__time + .github-import-comment__import {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.github-import-provider,
|
||||
.github-import-gitlab .github-import-toolbar {
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
apiFetchGitHubIssueDetail,
|
||||
apiCloseGitHubIssue,
|
||||
apiImportGitHubPull,
|
||||
apiImportGitHubComment,
|
||||
apiFetchGitLabProjectIssues,
|
||||
apiFetchGitLabGroupIssues,
|
||||
apiFetchGitLabMergeRequests,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
type GitRemote,
|
||||
type GitLabImportItem,
|
||||
} from "../api";
|
||||
import { Loader2, RefreshCw, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User, Filter } from "lucide-react";
|
||||
import { Loader2, RefreshCw, GitPullRequest, CircleDot, ChevronUp, ChevronDown, Bot, User, Filter, ListPlus } from "lucide-react";
|
||||
import { GithubIcon } from "./GithubIcon";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
import {
|
||||
@@ -102,6 +103,12 @@ function CommentsThread({
|
||||
errorTestId,
|
||||
emptyTestId,
|
||||
bodyTestId,
|
||||
owner,
|
||||
repo,
|
||||
number,
|
||||
sourceType,
|
||||
projectId,
|
||||
onImport,
|
||||
t,
|
||||
}: {
|
||||
comments: GitHubCommentDetail[];
|
||||
@@ -113,6 +120,12 @@ function CommentsThread({
|
||||
errorTestId: string;
|
||||
emptyTestId: string;
|
||||
bodyTestId: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
number: number;
|
||||
sourceType: "issue" | "pull";
|
||||
projectId?: string;
|
||||
onImport: (task: Task) => void;
|
||||
t: TFunction<"app">;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<CommentFilter>("all");
|
||||
@@ -121,6 +134,8 @@ function CommentsThread({
|
||||
const commentRefs = useRef<Array<HTMLLIElement | null>>([]);
|
||||
// Avatar URLs that failed to load fall back to a generic lucide icon.
|
||||
const [brokenAvatars, setBrokenAvatars] = useState<Set<string>>(new Set());
|
||||
const [importingCommentKeys, setImportingCommentKeys] = useState<Set<string>>(new Set());
|
||||
const [commentImportResult, setCommentImportResult] = useState<{ key: string; kind: "success" | "error"; message: string } | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === "human") return comments.filter((c) => !c.authorIsBot);
|
||||
@@ -160,6 +175,29 @@ function CommentsThread({
|
||||
});
|
||||
}, [scrollToIndex, filtered.length]);
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-16-18:10:
|
||||
Each real comment row can create its own resolve-feedback task. The filtered-list index is deliberately part of the key: identical author/body/timestamp comments must retain independent loading and retry state.
|
||||
*/
|
||||
const handleImportComment = useCallback(async (comment: GitHubCommentDetail, key: string) => {
|
||||
setImportingCommentKeys((current) => new Set(current).add(key));
|
||||
setCommentImportResult(null);
|
||||
try {
|
||||
const task = await apiImportGitHubComment({ owner, repo, number, type: sourceType, comment }, projectId);
|
||||
onImport(task);
|
||||
setCommentImportResult({ key, kind: "success", message: t("git.commentImported", "Comment imported as a task") });
|
||||
window.setTimeout(() => setCommentImportResult((current) => current?.key === key ? null : current), 4000);
|
||||
} catch (err) {
|
||||
setCommentImportResult({ key, kind: "error", message: getErrorMessage(err) || t("git.failedToImportComment", "Failed to import comment") });
|
||||
} finally {
|
||||
setImportingCommentKeys((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [number, onImport, owner, projectId, repo, sourceType, t]);
|
||||
|
||||
const renderFilter = (
|
||||
<div className="github-import-comments-filter" data-testid="github-import-comments-filter" role="group" aria-label={t("git.filterCommentsAriaLabel", "Filter comments by author type")}>
|
||||
{(["all", "human", "bot"] as CommentFilter[]).map((mode) => (
|
||||
@@ -231,6 +269,9 @@ function CommentsThread({
|
||||
const authorType = comment.authorIsBot ? "bot" : "human";
|
||||
const timestamp = formatCommentTimestamp(comment.createdAt);
|
||||
const avatarKey = `${comment.author}-${idx}`;
|
||||
const commentKey = `${sourceType}:${number}:${idx}`;
|
||||
const importingComment = importingCommentKeys.has(commentKey);
|
||||
const result = commentImportResult?.key === commentKey ? commentImportResult : null;
|
||||
const showAvatarImg = comment.authorAvatarUrl && !brokenAvatars.has(avatarKey);
|
||||
return (
|
||||
<li
|
||||
@@ -265,7 +306,23 @@ function CommentsThread({
|
||||
{timestamp}
|
||||
</time>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn github-import-comment__import"
|
||||
data-testid="github-import-comment-import"
|
||||
onClick={() => void handleImportComment(comment, commentKey)}
|
||||
disabled={importingComment}
|
||||
aria-label={t("git.importCommentAsTask", "Import as task")}
|
||||
>
|
||||
{importingComment ? <Loader2 className="spin" aria-hidden="true" /> : <ListPlus aria-hidden="true" />}
|
||||
{t("git.importCommentAsTask", "Import as task")}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<div className={`github-import-comment__result github-import-comment__result--${result.kind}`} role="status">
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
<MailboxMessageContent
|
||||
className="github-import-pr-comment__body preview-body--markdown"
|
||||
content={comment.body || t("git.noCommentBody", "(empty comment)")}
|
||||
@@ -1884,6 +1941,12 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
errorTestId="github-import-issue-comments-error"
|
||||
emptyTestId="github-import-issue-comments-empty"
|
||||
bodyTestId="github-import-issue-comment-body"
|
||||
owner={owner.trim()}
|
||||
repo={repo.trim()}
|
||||
number={selectedIssue.number}
|
||||
sourceType="issue"
|
||||
projectId={projectId}
|
||||
onImport={onImport}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
@@ -2009,6 +2072,12 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
errorTestId="github-import-pr-comments-error"
|
||||
emptyTestId="github-import-pr-comments-empty"
|
||||
bodyTestId="github-import-pr-comment-body"
|
||||
owner={owner.trim()}
|
||||
repo={repo.trim()}
|
||||
number={selectedPull.number}
|
||||
sourceType="pull"
|
||||
projectId={projectId}
|
||||
onImport={onImport}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
@@ -2022,13 +2091,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
) : null}
|
||||
</div>
|
||||
{/*
|
||||
FNXC:GitHubImport 2026-07-15-23:20:
|
||||
Bottom action bar for the detail preview — the ONLY place an import is committed now
|
||||
(the list's footer Import was removed, so you can no longer import an issue whose body
|
||||
you never opened). Import stays last/right as the primary action; Close issue sits to
|
||||
its LEFT and still acts on the selected OPEN issue: hidden on the PR tab and for
|
||||
already-closed issues, disabled while a close is in flight, and closing reflects
|
||||
locally (the badge flips) without dismissing the preview.
|
||||
FNXC:GitHubImport 2026-07-16-18:10:
|
||||
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 && !selectedIssueClosed && (
|
||||
@@ -2050,7 +2114,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
(activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
|
||||
}
|
||||
>
|
||||
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
|
||||
{importing ? <Loader2 size={14} className="spin" /> : activeTab === "pulls" ? t("git.resolveFeedback", "Resolve feedback") : t("git.import", "Import")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
apiFetchGitHubIssueDetail,
|
||||
apiCloseGitHubIssue,
|
||||
apiImportGitHubPull,
|
||||
apiImportGitHubComment,
|
||||
apiFetchGitLabProjectIssues,
|
||||
apiFetchGitLabGroupIssues,
|
||||
apiFetchGitLabMergeRequests,
|
||||
@@ -38,6 +39,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
apiFetchGitHubIssueDetail: vi.fn(),
|
||||
apiCloseGitHubIssue: vi.fn(),
|
||||
apiImportGitHubPull: vi.fn(),
|
||||
apiImportGitHubComment: vi.fn(),
|
||||
apiFetchGitLabProjectIssues: vi.fn(),
|
||||
apiFetchGitLabGroupIssues: vi.fn(),
|
||||
apiFetchGitLabMergeRequests: vi.fn(),
|
||||
@@ -171,6 +173,7 @@ describe("GitHubImportModal", () => {
|
||||
vi.mocked(apiFetchGitHubIssueDetail).mockReset();
|
||||
vi.mocked(apiCloseGitHubIssue).mockReset();
|
||||
vi.mocked(apiImportGitHubPull).mockReset();
|
||||
vi.mocked(apiImportGitHubComment).mockReset();
|
||||
vi.mocked(apiFetchGitLabProjectIssues).mockReset();
|
||||
vi.mocked(apiFetchGitLabGroupIssues).mockReset();
|
||||
vi.mocked(apiFetchGitLabMergeRequests).mockReset();
|
||||
@@ -187,6 +190,7 @@ describe("GitHubImportModal", () => {
|
||||
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValue({ comments: [], checks: [] });
|
||||
vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValue({ comments: [] });
|
||||
vi.mocked(apiCloseGitHubIssue).mockResolvedValue(undefined);
|
||||
vi.mocked(apiImportGitHubComment).mockResolvedValue(mockTask);
|
||||
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValue([]);
|
||||
vi.mocked(apiFetchGitLabGroupIssues).mockResolvedValue([]);
|
||||
vi.mocked(apiFetchGitLabMergeRequests).mockResolvedValue([]);
|
||||
@@ -1304,6 +1308,58 @@ describe("GitHubImportModal", () => {
|
||||
expect(timeEl?.textContent?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-16-18:20:
|
||||
PR and issue comment threads share the same import affordance; this test protects the source context passed from each parent call site and confirms bot feedback remains actionable.
|
||||
*/
|
||||
it("imports human and bot feedback from PR and issue comment threads", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
|
||||
vi.mocked(apiFetchGitHubPulls).mockResolvedValue([{ number: 21, title: "Feedback PR", body: "body", html_url: "https://github.com/owner/repo/pull/21", headBranch: "feature", baseBranch: "main" }]);
|
||||
vi.mocked(apiFetchGitHubPullDetail).mockResolvedValue({ comments: [
|
||||
{ author: "reviewer", body: "Please test this", createdAt: "2026-07-16T00:00:00Z", authorIsBot: false },
|
||||
{ author: "github-actions[bot]", body: "CI failed", createdAt: "2026-07-16T01:00:00Z", authorIsBot: true },
|
||||
], checks: [] });
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Select pull request #21/i }));
|
||||
const prComments = await screen.findByTestId("github-import-pr-comments");
|
||||
const prButtons = await within(prComments).findAllByTestId("github-import-comment-import");
|
||||
expect(prButtons).toHaveLength(2);
|
||||
fireEvent.click(prButtons[1]);
|
||||
await waitFor(() => expect(vi.mocked(apiImportGitHubComment)).toHaveBeenCalledWith({
|
||||
owner: "dustinbyrne", repo: "kb", number: 21, type: "pull",
|
||||
comment: { author: "github-actions[bot]", body: "CI failed", createdAt: "2026-07-16T01:00:00Z", authorIsBot: true },
|
||||
}, "project-1"));
|
||||
expect(onImport).toHaveBeenCalledWith(mockTask);
|
||||
expect(await within(prComments).findByText("Comment imported as a task")).toBeTruthy();
|
||||
expect(screen.getByTestId("github-import-detail-actions")).toBeTruthy();
|
||||
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([{ number: 22, title: "Feedback issue", body: "body", html_url: "https://github.com/owner/repo/issues/22", labels: [], state: "open", author: "owner" }]);
|
||||
vi.mocked(apiFetchGitHubIssueDetail).mockResolvedValue({ comments: [{ author: "issue-reviewer", body: "Address this", createdAt: "2026-07-16T02:00:00Z", authorIsBot: false }] });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Issues" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Select issue #22/i }));
|
||||
const issueComments = await screen.findByTestId("github-import-issue-comments");
|
||||
fireEvent.click(await within(issueComments).findByTestId("github-import-comment-import"));
|
||||
await waitFor(() => expect(vi.mocked(apiImportGitHubComment)).toHaveBeenLastCalledWith({
|
||||
owner: "dustinbyrne", repo: "kb", number: 22, type: "issue",
|
||||
comment: { author: "issue-reviewer", body: "Address this", createdAt: "2026-07-16T02:00:00Z", authorIsBot: false },
|
||||
}, "project-1"));
|
||||
});
|
||||
|
||||
it("shows Resolve feedback only for the pull request detail action", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
|
||||
vi.mocked(apiFetchGitHubPulls).mockResolvedValue([{ number: 23, title: "Action PR", body: "body", html_url: "https://github.com/owner/repo/pull/23", headBranch: "feature", baseBranch: "main" }]);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([{ number: 24, title: "Action issue", body: "body", html_url: "https://github.com/owner/repo/issues/24", labels: [], state: "open", author: "owner" }]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} presentation="embedded" />);
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Select pull request #23/i }));
|
||||
expect(await screen.findByRole("button", { name: "Resolve feedback" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Issues" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Select issue #24/i }));
|
||||
expect(await screen.findByRole("button", { name: "Import" })).toBeTruthy();
|
||||
});
|
||||
|
||||
// FNXC:GitHubImport 2026-06-23-03:30: The Human filter hides bot comments; All (default) shows both.
|
||||
it("filters bot comments out when the comments filter is set to Human", async () => {
|
||||
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
|
||||
|
||||
@@ -1636,6 +1636,79 @@ describe("POST /github/issues/batch-import", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /github/pulls/import and /github/comments/import", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsGhAuthenticated.mockReturnValue(true);
|
||||
store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-FEEDBACK", column: "triage" }),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("creates resolve-feedback PR tasks while retaining provenance and deduplication", async () => {
|
||||
vi.spyOn(GitHubClient.prototype, "getPullRequest").mockResolvedValue({
|
||||
number: 9,
|
||||
title: "Feedback PR",
|
||||
body: "PR body",
|
||||
html_url: "https://github.com/owner/repo/pull/9",
|
||||
headBranch: "feature/feedback",
|
||||
baseBranch: "main",
|
||||
state: "open",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/github/pulls/import", JSON.stringify({ owner: "owner", repo: "repo", prNumber: 9 }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "Resolve feedback: PR #9 — Feedback PR",
|
||||
description: expect.stringContaining("Resolve the pull request review feedback and address any failed CI checks."),
|
||||
}));
|
||||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: expect.stringContaining("PR: https://github.com/owner/repo/pull/9\nBranch: feature/feedback → main\n\nPR body"),
|
||||
}));
|
||||
});
|
||||
|
||||
it("creates comment feedback tasks without deduplicating repeated imports", async () => {
|
||||
const payload = { owner: "owner", repo: "repo", number: 9, type: "pull", comment: { author: "reviewer", body: "Please add coverage", createdAt: "2026-07-16T00:00:00Z" } };
|
||||
const first = await REQUEST(buildApp(), "POST", "/api/github/comments/import", JSON.stringify(payload), { "Content-Type": "application/json" });
|
||||
const second = await REQUEST(buildApp(), "POST", "/api/github/comments/import", JSON.stringify(payload), { "Content-Type": "application/json" });
|
||||
|
||||
expect(first.status).toBe(201);
|
||||
expect(second.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledTimes(2);
|
||||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "Resolve feedback from @reviewer on #9",
|
||||
description: "Resolve or address this feedback comment.\n\n> reviewer\n> Please add coverage\n\nSource: https://github.com/owner/repo/pull/9",
|
||||
}));
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-FEEDBACK", "Imported PR/issue comment from GitHub", "https://github.com/owner/repo/pull/9");
|
||||
});
|
||||
|
||||
it("requires GitHub authentication and complete comment payloads", async () => {
|
||||
const invalid = await REQUEST(buildApp(), "POST", "/api/github/comments/import", JSON.stringify({ owner: "owner" }), { "Content-Type": "application/json" });
|
||||
expect(invalid.status).toBe(400);
|
||||
|
||||
mockIsGhAuthenticated.mockReturnValue(false);
|
||||
const unauthenticated = await REQUEST(buildApp(), "POST", "/api/github/comments/import", JSON.stringify({
|
||||
owner: "owner", repo: "repo", number: 1, type: "issue", comment: { author: "reviewer", body: "Fix it" },
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(unauthenticated.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectId store scoping regressions", () => {
|
||||
const projectId = "proj-scoped";
|
||||
let defaultStore: TaskStore;
|
||||
|
||||
@@ -4787,10 +4787,13 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Create the task with "Review PR:" prefix
|
||||
const title = `Review PR #${pr.number}: ${pr.title.slice(0, 180)}`;
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-16-18:00:
|
||||
PR imports are resolve-feedback work, so their executor prompt must explicitly cover reviewer feedback and failed CI while retaining URL, branch, and body provenance for deduplication and auditability.
|
||||
*/
|
||||
const title = `Resolve feedback: PR #${pr.number} — ${pr.title.slice(0, 180)}`;
|
||||
const body = pr.body?.trim() || "(no description)";
|
||||
const description = `Review and address any issues in this pull request.\n\nPR: ${sourceUrl}\nBranch: ${pr.headBranch} → ${pr.baseBranch}\n\n${body}`;
|
||||
const description = `Resolve the pull request review feedback and address any failed CI checks.\n\nPR: ${sourceUrl}\nBranch: ${pr.headBranch} → ${pr.baseBranch}\n\n${body}`;
|
||||
|
||||
// FNXC:Workflows 2026-07-05-00:00: FN-7611 — no workflowId here; let the store
|
||||
// resolve the project-default workflow's intake column (byte-identical "triage"
|
||||
@@ -4817,6 +4820,45 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-16-18:00:
|
||||
A reviewer or bot comment can become independent resolve-feedback work. Comments expose no stable source ID/URL, so repeated imports intentionally create separate tasks rather than applying PR-style deduplication.
|
||||
*/
|
||||
router.post("/github/comments/import", async (req, res) => {
|
||||
try {
|
||||
const { owner, repo, number, type, comment } = req.body ?? {};
|
||||
const author = comment?.author;
|
||||
const body = comment?.body;
|
||||
|
||||
if (!owner || typeof owner !== "string" || !owner.trim()) throw badRequest("owner is required");
|
||||
if (!repo || typeof repo !== "string" || !repo.trim()) throw badRequest("repo is required");
|
||||
if (!Number.isInteger(number) || number < 1) throw badRequest("number is required and must be a positive number");
|
||||
if (type !== "issue" && type !== "pull") throw badRequest("type must be 'issue' or 'pull'");
|
||||
if (!author || typeof author !== "string" || !author.trim()) throw badRequest("comment.author is required");
|
||||
if (!body || typeof body !== "string" || !body.trim()) throw badRequest("comment.body is required");
|
||||
if (comment.createdAt !== undefined && (typeof comment.createdAt !== "string" || !comment.createdAt.trim())) {
|
||||
throw badRequest("comment.createdAt must be a non-empty string when provided");
|
||||
}
|
||||
if (!isGhAuthenticated()) throw unauthorized("Not authenticated with GitHub. Run `gh auth login`.");
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const sourceUrl = `https://github.com/${owner.trim()}/${repo.trim()}/${type === "pull" ? "pull" : "issues"}/${number}`;
|
||||
const task = await scopedStore.createTask({
|
||||
title: `Resolve feedback from @${author.trim()} on #${number}`,
|
||||
description: `Resolve or address this feedback comment.\n\n> ${author.trim()}\n> ${body.trim().replace(/\n/g, "\n> ")}\n\nSource: ${sourceUrl}`,
|
||||
dependencies: [],
|
||||
source: {
|
||||
sourceType: "github_import",
|
||||
sourceMetadata: { sourceUrl, number, type, commentAuthor: author.trim(), commentCreatedAt: comment.createdAt },
|
||||
},
|
||||
});
|
||||
await scopedStore.logEntry(task.id, "Imported PR/issue comment from GitHub", sourceUrl);
|
||||
res.status(201).json(task);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/github/webhooks
|
||||
|
||||
Reference in New Issue
Block a user