Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion prd.json
Original file line number Diff line number Diff line change
Expand Up @@ -1890,7 +1890,7 @@
],
"needs_structure": true,
"build_pass": true,
"qa_pass": false
"qa_pass": true
},
{
"id": "projects-006",
Expand Down
50 changes: 42 additions & 8 deletions qa-report-summary.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,29 @@
],
"totals": {
"features_total": 119,
"features_passed": 85,
"features_passed": 86,
"features_failed": 1,
"features_exhausted": 0,
"features_crashed": 0
},
"sub_phase_totals": {
"functional": {
"pass": 81,
"pass": 82,
"fail": 0,
"skip": 0
},
"api_contract": {
"pass": 77,
"pass": 78,
"fail": 0,
"skip": 4
},
"security": {
"pass": 78,
"pass": 79,
"fail": 0,
"skip": 3
},
"accessibility": {
"pass": 43,
"pass": 44,
"fail": 0,
"skip": 39
}
Expand Down Expand Up @@ -2861,10 +2861,44 @@
"feature_id": "projects-005",
"description": "Implement project items, draft issues, linked issue/PR sync, conversion of draft",
"category": "crud",
"qa_pass": false,
"attempts": 0,
"qa_pass": true,
"attempts": 1,
"exhausted": false,
"sub_phases": {}
"sub_phases": {
"functional": {
"status": "pass",
"notes": "Verified the Projects item side panel lifecycle against the real .env.test Postgres using system Chrome. The E2E now enables the projects workspace seed fixture, opens the seeded workspace href directly, opens the draft item detail panel, edits title/body, adds a project-only comment, converts the draft to an issue, archives it, views/restores it from the archive page, removes an item, and repeats a mobile side-panel smoke."
},
"api_contract": {
"status": "pass",
"endpoints_tested": [
"GET /api/projects/:project_id/workspace",
"GET /api/projects/:project_id/items/:item_id detail via item route",
"PATCH /api/projects/:project_id/items/:item_id/draft",
"POST /api/projects/:project_id/items/:item_id/comments",
"POST /api/projects/:project_id/items/:item_id/convert-to-issue",
"PATCH /api/projects/:project_id/items/:item_id/archive",
"GET /api/projects/:project_id/archived-items",
"PATCH /api/projects/:project_id/items/:item_id/restore",
"DELETE /api/projects/:project_id/items/:item_id"
],
"notes": "DB-backed projects_workspace_contract passed 15/15 with no self-skips, including project_item_detail_and_archived_list_enforce_visibility, project_draft_editing_and_comments_are_project_only, project_draft_conversion_creates_linked_issue, and project_workspace_adds_reorders_and_removes_items."
},
"security": {
"status": "pass",
"checks": [
"private project item detail visibility remains covered by projects_workspace_contract",
"archive list visibility remains covered by projects_workspace_contract",
"real signed-in browser flow used seeded authenticated session instead of bypassing auth"
],
"notes": "No endpoint or auth weakening was introduced; the browser gate exercises authenticated UI against the real DB, and the Rust contract confirms private guards for item detail and archive list."
},
"accessibility": {
"status": "pass",
"violations": [],
"notes": "Focused browser smoke asserts the complementary Project item detail landmark, named forms/buttons/links, no dead href controls, and no page overflow in desktop and mobile viewport."
}
}
},
{
"feature_id": "projects-006",
Expand Down
73 changes: 73 additions & 0 deletions qa-report.json
Original file line number Diff line number Diff line change
Expand Up @@ -6007,5 +6007,78 @@
"set -a; . ./.env.test; set +a; ./hack/cargo_locked.sh test -p opengithub-api --test projects_workspace_contract project_iteration_settings_create_breaks_and_filter_tokens -- --nocapture",
"cd web && set -a; . ../.env.test; set +a; PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome npx playwright test --project=chromium tests/e2e/projects-field-settings.spec.ts"
]
},
{
"feature_id": "projects-005",
"attempt": 1,
"status": "pass",
"sub_phases": {
"functional": {
"status": "pass",
"notes": "Verified the Projects item side panel lifecycle against the real .env.test Postgres using system Chrome. The E2E now enables the projects workspace seed fixture, opens the seeded workspace href directly, opens the draft item detail panel, edits title/body, adds a project-only comment, converts the draft to an issue, archives it, views/restores it from the archive page, removes an item, and repeats a mobile side-panel smoke."
},
"api_contract": {
"status": "pass",
"endpoints_tested": [
"GET /api/projects/:project_id/workspace",
"GET /api/projects/:project_id/items/:item_id detail via item route",
"PATCH /api/projects/:project_id/items/:item_id/draft",
"POST /api/projects/:project_id/items/:item_id/comments",
"POST /api/projects/:project_id/items/:item_id/convert-to-issue",
"PATCH /api/projects/:project_id/items/:item_id/archive",
"GET /api/projects/:project_id/archived-items",
"PATCH /api/projects/:project_id/items/:item_id/restore",
"DELETE /api/projects/:project_id/items/:item_id"
],
"notes": "DB-backed projects_workspace_contract passed 15/15 with no self-skips, including project_item_detail_and_archived_list_enforce_visibility, project_draft_editing_and_comments_are_project_only, project_draft_conversion_creates_linked_issue, and project_workspace_adds_reorders_and_removes_items."
},
"security": {
"status": "pass",
"checks": [
"private project item detail visibility remains covered by projects_workspace_contract",
"archive list visibility remains covered by projects_workspace_contract",
"real signed-in browser flow used seeded authenticated session instead of bypassing auth"
],
"notes": "No endpoint or auth weakening was introduced; the browser gate exercises authenticated UI against the real DB, and the Rust contract confirms private guards for item detail and archive list."
},
"accessibility": {
"status": "pass",
"violations": [],
"notes": "Focused browser smoke asserts the complementary Project item detail landmark, named forms/buttons/links, no dead href controls, and no page overflow in desktop and mobile viewport."
}
},
"tested_steps": [
"export CARGO_TARGET_DIR=\"$PWD/.scratch/cargo-target\"; make doctor: passed; container runtime, opengithub-postgres-test on :55433, .env/.env.test, and Cargo target dir all healthy.",
"Reproduced origin/main failure: env-loaded system-Chrome Playwright spec failed because /orgs/namuh/projects listed Projects 0 and no /projects/*/views/* workspace link without PROJECTS_WORKSPACE_E2E seed.",
"export CARGO_TARGET_DIR=\"$PWD/.scratch/cargo-target\"; set -a; . ./.env.test; set +a; ./hack/cargo_locked.sh test -p opengithub-api --test projects_workspace_contract -- --nocapture: passed; 15 passed, 0 failed, no self-skips.",
"export CARGO_TARGET_DIR=\"$PWD/.scratch/cargo-target\"; make check: passed; Cargo check, Web typecheck, Clippy, and Biome passed.",
"cd web && npx vitest run tests/project-workspace-page.test.tsx: passed; 1 file / 19 tests passed.",
"cd web && set -a; . ../.env.test; set +a; PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome CARGO_TARGET_DIR=\"$PWD/../.scratch/cargo-target\" npx playwright test tests/e2e/projects-items-side-panel.spec.ts --project=chromium: passed; 2 passed including auth setup and final item lifecycle smoke."
],
"bugs_found": [
"The side-panel E2E did not enable the projects workspace DB fixture, so a clean .env.test run could land on /orgs/namuh/projects with Projects 0 and never reach the item side panel.",
"The test relied on broad project-list link discovery instead of the seeded workspace href returned by the seeder, making the acceptance path nondeterministic.",
"After item removal from the side panel, the success message could be lost during navigation back to the workspace; the product now carries a notice query and renders the confirmation on return.",
"Archive Back to project used the API-provided selectedView.href, which was not guaranteed to be the routed org/user workspace URL expected by the browser path."
],
"fixes_applied": [
"Enabled PROJECTS_WORKSPACE_E2E in the projects item side-panel spec seeder and opened seeded.projectsWorkspaceHref directly.",
"Targeted the seeded draft item by accessible link name and scoped Project-only draft assertions to the side-panel metadata paragraph.",
"Preserved side-panel removal confirmation across the route change with a notice query parameter.",
"Computed archive Back to project href from scope/owner/project number/view number for stable routed navigation."
],
"artifacts": [
"web/test-results/projects-items-side-panel--65877--final-item-lifecycle-smoke-chromium/error-context.md",
"web/test-results/projects-items-side-panel--65877--final-item-lifecycle-smoke-chromium/trace.zip",
"ralph/screenshots/build/projects-005-final-item-panel.jpg",
"ralph/screenshots/build/projects-005-final-draft-editor.jpg",
"ralph/screenshots/build/projects-005-final-convert-dialog.jpg",
"ralph/screenshots/build/projects-005-final-linked-issue-sync.jpg",
"ralph/screenshots/build/projects-005-final-archive-confirmation.jpg",
"ralph/screenshots/build/projects-005-final-archived-list.jpg",
"ralph/screenshots/build/projects-005-final-restore-confirmation.jpg",
"ralph/screenshots/build/projects-005-final-mobile.jpg"
],
"remaining_risks": []
}
]
15 changes: 13 additions & 2 deletions web/src/components/ProjectArchivedItemsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import Link from "next/link";
import { useState } from "react";
import type { ProjectArchivedItem, ProjectWorkspace } from "@/lib/api";
import { projectItemHref } from "@/lib/navigation";
import {
organizationProjectWorkspaceHref,
projectItemHref,
userProjectWorkspaceHref,
} from "@/lib/navigation";

type ProjectArchivedItemsPageProps = {
workspace: ProjectWorkspace;
Expand Down Expand Up @@ -47,7 +51,14 @@ export function ProjectArchivedItemsPage({
setMessage("Item restored");
}

const workspaceHref = workspace.selectedView.href;
const workspaceHref =
scope === "organization"
? organizationProjectWorkspaceHref(
owner,
workspace.project.number,
viewNumber,
)
: userProjectWorkspaceHref(owner, workspace.project.number, viewNumber);
return (
<main className="mx-auto w-full max-w-[980px] px-5 py-6 md:px-8">
<div className="mb-5 flex flex-wrap items-start justify-between gap-4">
Expand Down
30 changes: 27 additions & 3 deletions web/src/components/ProjectWorkspacePage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import Link from "next/link";
import { type FormEvent, Fragment, useMemo, useState } from "react";
import { type FormEvent, Fragment, useEffect, useMemo, useState } from "react";
import type {
ProjectConversionTargets,
ProjectItemComment,
Expand Down Expand Up @@ -231,6 +231,14 @@ function requestValueForField(field: ProjectWorkspaceField, raw: string) {
return raw;
}

function hrefWithNotice(href: string, notice: string) {
const [path, hash = ""] = href.split("#", 2);
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}notice=${encodeURIComponent(notice)}${
hash ? `#${hash}` : ""
}`;
}

export function ProjectWorkspacePage({
workspace,
scope,
Expand Down Expand Up @@ -282,6 +290,7 @@ export function ProjectWorkspacePage({
const [itemSaving, setItemSaving] = useState(false);
const [itemMessage, setItemMessage] = useState<string | null>(null);
const [itemError, setItemError] = useState<string | null>(null);
const [noticeMessage, setNoticeMessage] = useState<string | null>(null);
const [emptyColumnsVisible, setEmptyColumnsVisible] = useState(
workspace.boardConfig?.emptyColumnsVisible ?? true,
);
Expand Down Expand Up @@ -341,6 +350,15 @@ export function ProjectWorkspacePage({
view: viewNumber,
};

useEffect(() => {
setNoticeMessage(
new URLSearchParams(window.location.search).get("notice") ===
"item_removed"
? "Item removed from project."
: null,
);
}, []);

function submitFilter(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const href = workspaceHref(
Expand Down Expand Up @@ -713,8 +731,8 @@ export function ProjectWorkspacePage({
);
return;
}
setItemMessage("Item removed");
window.location.assign(currentHref);
setItemMessage("Item removed from project.");
window.location.assign(hrefWithNotice(currentHref, "item_removed"));
}

return (
Expand Down Expand Up @@ -1659,6 +1677,9 @@ export function ProjectWorkspacePage({
{itemMessage ? (
<span className="chip ok">{itemMessage}</span>
) : null}
{noticeMessage ? (
<span className="chip ok">{noticeMessage}</span>
) : null}
<button
className="btn sm"
disabled={!workspace.viewerPermissions.canAddItems}
Expand Down Expand Up @@ -1949,6 +1970,9 @@ export function ProjectWorkspacePage({
{itemMessage ? (
<span className="chip ok">{itemMessage}</span>
) : null}
{noticeMessage ? (
<span className="chip ok">{noticeMessage}</span>
) : null}
<button
className="btn sm"
disabled={!workspace.viewerPermissions.canAddItems}
Expand Down
62 changes: 25 additions & 37 deletions web/tests/e2e/projects-items-side-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const databaseUrl = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
type SeededNavigation = {
cookieName: string;
cookieValue: string;
projectsWorkspaceHref: string;
};

function seedNavigation(): SeededNavigation {
Expand All @@ -28,6 +29,7 @@ function seedNavigation(): SeededNavigation {
env: {
...process.env,
DASHBOARD_E2E_EMPTY: "0",
PROJECTS_WORKSPACE_E2E: "1",
SESSION_COOKIE_NAME: "og_session",
},
},
Expand All @@ -49,14 +51,9 @@ async function signIn(page: Page, seeded: SeededNavigation) {
]);
}

async function openFirstProjectWorkspace(page: Page) {
await page.goto("/orgs/namuh/projects");
await expect(page.getByRole("heading", { name: /Projects/i })).toBeVisible();
const workspaceLink = page
.locator('a[href*="/projects/"][href*="/views/"]')
.first();
await expect(workspaceLink).toBeVisible();
await workspaceLink.click();
async function openFirstProjectWorkspace(page: Page, workspaceHref: string) {
expect(workspaceHref).toMatch(/\/projects\/\d+\/views\/\d+/);
await page.goto(workspaceHref);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
}

Expand All @@ -74,33 +71,22 @@ async function expectNoPageOverflow(page: Page) {
expect(overflow).toBe(false);
}

async function projectItemLinks(page: Page): Promise<Locator[]> {
return page.locator('a[href*="/projects/"][href*="/items/"]').all();
}

async function openDraftItemPanel(page: Page): Promise<Locator> {
const links = await projectItemLinks(page);
expect(links.length).toBeGreaterThan(0);

for (const link of links.slice(0, 8)) {
await link.click();
const panel = page.getByRole("complementary", {
name: "Project item detail",
});
await expect(panel).toBeVisible();
if (await panel.getByText("Project-only draft").isVisible()) {
return panel;
}
await panel.getByRole("link", { name: "Close" }).click();
}

throw new Error("Seeded Projects workspace did not include a draft item");
await page.getByRole("link", { name: "Draft launch notes" }).click();
const panel = page.getByRole("complementary", {
name: "Project item detail",
});
await expect(panel).toBeVisible();
await expect(
panel.locator("p", { hasText: "Project-only draft" }),
).toBeVisible();
return panel;
}

async function openAnyItemPanel(page: Page): Promise<Locator> {
const links = await projectItemLinks(page);
expect(links.length).toBeGreaterThan(0);
await links[0].click();
const link = page.locator('a[href*="/projects/"][href*="/items/"]').first();
await expect(link).toBeVisible();
await link.click();
const panel = page.getByRole("complementary", {
name: "Project item detail",
});
Expand All @@ -118,14 +104,16 @@ test("Projects item side panel supports final item lifecycle smoke", async ({
}) => {
const seeded = seedNavigation();
await signIn(page, seeded);
await openFirstProjectWorkspace(page);
await openFirstProjectWorkspace(page, seeded.projectsWorkspaceHref);

await expect(page.getByRole("table")).toBeVisible();
await expectNoDeadControls(page);
await expectNoPageOverflow(page);

const panel = await openDraftItemPanel(page);
await expect(panel.getByText("Project-only draft")).toBeVisible();
await expect(
panel.locator("p", { hasText: "Project-only draft" }),
).toBeVisible();
await expect(
panel.getByRole("form", { name: "Edit draft project item" }),
).toBeVisible();
Expand Down Expand Up @@ -167,7 +155,9 @@ test("Projects item side panel supports final item lifecycle smoke", async ({
});
await panel.getByRole("button", { name: "Convert draft" }).click();
await expect(panel.getByText("Draft converted to issue")).toBeVisible();
await expect(panel.getByText("Project-only draft")).not.toBeVisible();
await expect(
panel.locator("p", { hasText: "Project-only draft" }),
).not.toBeVisible();
await page.screenshot({
fullPage: true,
path: "../ralph/screenshots/build/projects-005-final-linked-issue-sync.jpg",
Expand All @@ -181,9 +171,7 @@ test("Projects item side panel supports final item lifecycle smoke", async ({
});

await panel.getByRole("link", { name: "View archived items" }).click();
await expect(
page.getByRole("heading", { name: /Project archive/i }),
).toBeVisible();
await expect(page.getByText("Project archive")).toBeVisible();
await expect(
page.getByRole("button", { name: "Restore" }).first(),
).toBeVisible();
Expand Down
Loading