diff --git a/crates/api/examples/dashboard_e2e_seed.rs b/crates/api/examples/dashboard_e2e_seed.rs index 1e2a64b1..c42af087 100644 --- a/crates/api/examples/dashboard_e2e_seed.rs +++ b/crates/api/examples/dashboard_e2e_seed.rs @@ -73,6 +73,13 @@ fn seed_tree_repository() -> bool { ) } +fn seed_dependency_graph() -> bool { + matches!( + std::env::var("DASHBOARD_E2E_DEPENDENCY_GRAPH").as_deref(), + Ok("1" | "true" | "yes") + ) +} + fn seed_fork_compare_repository() -> bool { matches!( std::env::var("DASHBOARD_E2E_FORK_REFS").as_deref(), @@ -299,6 +306,9 @@ async fn main() -> anyhow::Result<()> { .await?; seed_tree_refs(&pool, user.id, tree_repository.id).await?; seed_repository_traffic(&pool, tree_repository.id).await?; + if seed_dependency_graph() { + seed_dependency_graph_fixture(&pool, tree_repository.id, &suffix).await?; + } if seed_blob_edge_files() { seed_blob_edge_cases(&pool, tree_repository.id).await?; } @@ -2190,6 +2200,176 @@ Describe the change. Ok(()) } +async fn seed_dependency_graph_fixture( + pool: &PgPool, + repository_id: Uuid, + suffix: &str, +) -> anyhow::Result<()> { + let commit_id = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT target_commit_id + FROM repository_git_refs + WHERE repository_id = $1 AND name = 'refs/heads/main' + "#, + ) + .bind(repository_id) + .fetch_one(pool) + .await?; + let short_suffix = &suffix[..12]; + for (path, content) in [ + ( + "package.json", + r#"{"dependencies":{"@playwright/test":"^1.56.0"},"devDependencies":{"vitest":"^4.0.0"}}"#, + ), + ( + "package-lock.json", + r#"{"packages":{"node_modules/@playwright/test":{"version":"1.56.0"},"node_modules/vitest":{"version":"4.0.0"}}}"#, + ), + ( + "crates/api/Cargo.toml", + r#"[package] +name = "opengithub-api" +[dependencies] +sqlx = "0.8" +"#, + ), + ] { + sqlx::query( + r#" + INSERT INTO repository_files (repository_id, commit_id, path, content, oid, byte_size) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (repository_id, commit_id, lower(path)) + DO UPDATE SET content = EXCLUDED.content, oid = EXCLUDED.oid, byte_size = EXCLUDED.byte_size + "#, + ) + .bind(repository_id) + .bind(commit_id) + .bind(path) + .bind(content) + .bind(format!("dependency-{short_suffix}-{}", path.replace('/', "-"))) + .bind(content.len() as i64) + .execute(pool) + .await?; + } + + let package_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO dependency_packages (ecosystem, name, package_href) + VALUES ('npm', '@playwright/test', 'https://www.npmjs.com/package/@playwright/test') + ON CONFLICT (ecosystem, lower(name)) + DO UPDATE SET package_href = EXCLUDED.package_href, updated_at = now() + RETURNING id + "#, + ) + .fetch_one(pool) + .await?; + let manifest_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO dependency_manifests (repository_id, path, ecosystem, lockfile_path, dependency_count) + VALUES ($1, 'package.json', 'npm', 'package-lock.json', 2) + ON CONFLICT (repository_id, lower(path)) + DO UPDATE SET ecosystem = EXCLUDED.ecosystem, + lockfile_path = EXCLUDED.lockfile_path, + dependency_count = EXCLUDED.dependency_count, + updated_at = now() + RETURNING id + "#, + ) + .bind(repository_id) + .fetch_one(pool) + .await?; + sqlx::query( + r#" + INSERT INTO repository_dependencies ( + repository_id, manifest_id, package_id, package_version, relationship, license, lockfile_path + ) + VALUES ($1, $2, $3, '1.56.0', 'direct', 'Apache-2.0', 'package-lock.json') + ON CONFLICT (manifest_id, package_id, relationship) + DO UPDATE SET package_version = EXCLUDED.package_version, + license = EXCLUDED.license, + lockfile_path = EXCLUDED.lockfile_path, + updated_at = now() + "#, + ) + .bind(repository_id) + .bind(manifest_id) + .bind(package_id) + .execute(pool) + .await?; + + let public_owner_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO users (username, email, display_name, avatar_url) + VALUES ($1, $2, $3, NULL) + ON CONFLICT (lower(email)) DO UPDATE SET username = EXCLUDED.username + RETURNING id + "#, + ) + .bind(format!("public-consumer-{short_suffix}")) + .bind(format!("public-consumer-{short_suffix}@opengithub.local")) + .bind(format!("Public consumer {short_suffix}")) + .fetch_one(pool) + .await?; + let private_owner_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO users (username, email, display_name, avatar_url) + VALUES ($1, $2, $3, NULL) + ON CONFLICT (lower(email)) DO UPDATE SET username = EXCLUDED.username + RETURNING id + "#, + ) + .bind(format!("private-consumer-{short_suffix}")) + .bind(format!("private-consumer-{short_suffix}@opengithub.local")) + .bind(format!("Private consumer {short_suffix}")) + .fetch_one(pool) + .await?; + let public_repo_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO repositories (owner_user_id, name, description, visibility, default_branch, created_by_user_id) + VALUES ($1, $2, 'Uses the opengithub package in production.', 'public', 'main', $1) + ON CONFLICT (owner_user_id, lower(name)) WHERE owner_user_id IS NOT NULL + DO UPDATE SET description = EXCLUDED.description, visibility = 'public' + RETURNING id + "#, + ) + .bind(public_owner_id) + .bind(format!("workflow-tools-{short_suffix}")) + .fetch_one(pool) + .await?; + let private_repo_id = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO repositories (owner_user_id, name, description, visibility, default_branch, created_by_user_id) + VALUES ($1, $2, 'Private dependent repository.', 'private', 'main', $1) + ON CONFLICT (owner_user_id, lower(name)) WHERE owner_user_id IS NOT NULL + DO UPDATE SET description = EXCLUDED.description, visibility = 'private' + RETURNING id + "#, + ) + .bind(private_owner_id) + .bind(format!("private-workflow-tools-{short_suffix}")) + .fetch_one(pool) + .await?; + for dependent_repo_id in [public_repo_id, private_repo_id] { + sqlx::query( + r#" + INSERT INTO repository_dependents ( + source_repository_id, dependent_repository_id, package_id, manifest_path + ) + VALUES ($1, $2, $3, 'package.json') + ON CONFLICT (source_repository_id, dependent_repository_id, package_id) + DO UPDATE SET manifest_path = EXCLUDED.manifest_path, detected_at = now() + "#, + ) + .bind(repository_id) + .bind(dependent_repo_id) + .bind(package_id) + .execute(pool) + .await?; + } + + Ok(()) +} + async fn seed_fork_compare_refs( pool: &PgPool, user_id: Uuid, diff --git a/crates/api/tests/repository_dependency_graph_contract.rs b/crates/api/tests/repository_dependency_graph_contract.rs index a4632fab..e1cbe6a9 100644 --- a/crates/api/tests/repository_dependency_graph_contract.rs +++ b/crates/api/tests/repository_dependency_graph_contract.rs @@ -148,7 +148,10 @@ async fn request_json( cookie: Option<&str>, ) -> (StatusCode, Value) { let mut builder = Request::builder().uri(uri); - builder = builder.method(method); + builder = builder.method(method).header( + "x-forwarded-for", + format!("dependency-graph-contract-{}", Uuid::new_v4()), + ); if let Some(cookie) = cookie { builder = builder.header(header::COOKIE, cookie); } @@ -445,7 +448,11 @@ version = "0.5.2" .all(|dependency| dependency["package"]["ecosystem"] == "npm")); let (unauthenticated_status, unauthenticated_body) = get_json(app.clone(), &uri, None).await; - assert_eq!(unauthenticated_status, StatusCode::UNAUTHORIZED); + assert_eq!( + unauthenticated_status, + StatusCode::UNAUTHORIZED, + "{unauthenticated_body}" + ); assert_eq!(unauthenticated_body["error"]["code"], "not_authenticated"); let (outsider_status, outsider_body) = diff --git a/prd.json b/prd.json index c6eec4ca..2ba54d44 100644 --- a/prd.json +++ b/prd.json @@ -61,7 +61,7 @@ "priority": "P1", "ui_details": "Sanitized HTML inside .markdown-body container with Primer-style typography. Headings get auto anchor links (hover paragraph mark). Code blocks have copy button + language label. Tables get sticky header on long tables. Task lists are interactive when viewer has write access (POST toggle). Mermaid renders as SVG. KaTeX for math. Images autoresize <= container width. External links get rel=noopener noreferrer. Markdown editor toolbar (bold/italic/code/link/image/list/task/quote) with live Preview tab.", "behavior": "Server-side render via pulldown-cmark -> AST -> HTML; sanitize via ammonia (allowlist tags/attrs); rewrite relative image/link paths to repo blob URLs at the current ref; resolve @username and #issue references to live links; cache rendered HTML keyed by (content_sha, repo_id, ref). Editor preview hits the same render endpoint for fidelity.", - "data_model": "rendered_markdown_cache(content_sha, repository_id|null, ref|null, html, rendered_at) — TTL-managed; no user data of its own.", + "data_model": "rendered_markdown_cache(content_sha, repository_id|null, ref|null, html, rendered_at) \u2014 TTL-managed; no user data of its own.", "dependent_on": [ "infra-001" ], @@ -76,7 +76,7 @@ "priority": "P1", "ui_details": "Code surface with line numbers, gutter blame/comment-add button on hover, theme-aware token colors (light + dark), language label dropdown to override detection, Symbols sidebar (functions/classes from Tree-sitter outline), Wrap toggle, Find-in-file (Cmd-F).", "behavior": "At index time (search-006 worker), generate Tree-sitter token stream and store as compact byte ranges per (sha, path). At render: server returns text + token map; client renders HTML with token classes mapped to CSS variables defined by theme-001. Diff viewer applies same highlighting per line. Tree-sitter grammars compiled into the API binary.", - "data_model": "file_tokens(repository_id, sha, path, language, token_ranges bytea, generated_at) — generated once per (sha, path), cached.", + "data_model": "file_tokens(repository_id, sha, path, language, token_ranges bytea, generated_at) \u2014 generated once per (sha, path), cached.", "dependent_on": [ "theme-001", "search-006" @@ -249,7 +249,7 @@ "page": "/{owner}/{repo}/blob/{branch}/{path}", "priority": "P2", "core": true, - "ui_details": "Blob view keeps repository split-pane shell. Header shows branch selector, breadcrumbs, file name, Go to file, latest commit actor/message/SHA/time, History link, metadata text like '352 lines (352 loc) · 15.9 KB', Code and Blame toggles, Raw link, copy raw button, download button, symbol pane button, and More file actions. Code viewer uses monospace text, line numbers, syntax highlighting, line-hover anchors, and a read-only hidden textarea for accessibility.", + "ui_details": "Blob view keeps repository split-pane shell. Header shows branch selector, breadcrumbs, file name, Go to file, latest commit actor/message/SHA/time, History link, metadata text like '352 lines (352 loc) \u00b7 15.9 KB', Code and Blame toggles, Raw link, copy raw button, download button, symbol pane button, and More file actions. Code viewer uses monospace text, line numbers, syntax highlighting, line-hover anchors, and a read-only hidden textarea for accessibility.", "behavior": "Opening a file resolves the selected ref and renders text files with syntax highlighting. Raw streams raw bytes. Copy copies raw content or selected permalink. Download saves the raw file. Blame toggles to line-by-line commit attribution. Large/binary files show a non-renderable state with Raw/Download. Keyboard shortcuts include y for permalink, b for blame, and l for line jump.", "data_model": "Reads git_objects, git_trees, commits, repository_git_refs, code_search_index, repository_permissions, users, and optional symbol index records. Writes recent_visits and optional code navigation cache records.", "dependent_on": [ @@ -1552,7 +1552,7 @@ ], "needs_structure": true, "build_pass": true, - "qa_pass": false + "qa_pass": true }, { "id": "code-security-001", @@ -2150,7 +2150,7 @@ { "id": "search-007", "category": "feature", - "description": "File finder widget — keyboard-driven `t` shortcut on a repo to fuzzy-find a file by path within the current ref.", + "description": "File finder widget \u2014 keyboard-driven `t` shortcut on a repo to fuzzy-find a file by path within the current ref.", "page": "///find/", "priority": "P3", "ui_details": "Modal-style page with single text input, instant fuzzy match against full path list, keyboard nav (up/down), Enter to open. Empty input shows full list. Highlight matched chars.", @@ -2165,7 +2165,7 @@ { "id": "security-002", "category": "feature", - "description": "Active web sessions panel — list devices, locations, signed-in time; revoke individual sessions or sign-out-everywhere.", + "description": "Active web sessions panel \u2014 list devices, locations, signed-in time; revoke individual sessions or sign-out-everywhere.", "page": "/settings/security/sessions", "priority": "P1", "ui_details": "Table: device (parsed UA), browser, IP-derived location, last active, Revoke button per row, Sign-out-everywhere button, \"current\" badge on this session.", @@ -2180,7 +2180,7 @@ { "id": "security-003", "category": "feature", - "description": "Security log — append-only audit trail of security-relevant actions (sign-in, sign-out, OAuth link/unlink, email change, sudo confirmations, session revocations) with CSV/JSON export.", + "description": "Security log \u2014 append-only audit trail of security-relevant actions (sign-in, sign-out, OAuth link/unlink, email change, sudo confirmations, session revocations) with CSV/JSON export.", "page": "/settings/security-log", "priority": "P2", "ui_details": "Table: timestamp, action, IP, location, user-agent. Filter by action type. Pagination 50/page. Export dropdown -> CSV / JSON downloads filtered view.", @@ -2195,7 +2195,7 @@ { "id": "globalpulls-001", "category": "feature", - "description": "Global pull-request dashboard at /pulls — every PR the signed-in user opened, was assigned, was requested as reviewer, or that mentions them, across all repositories.", + "description": "Global pull-request dashboard at /pulls \u2014 every PR the signed-in user opened, was assigned, was requested as reviewer, or that mentions them, across all repositories.", "page": "/pulls", "priority": "P2", "ui_details": "Tab bar (Created / Assigned / Mentioned / Review requests). Filter dropdowns (Open/Closed, repo, label, milestone). Sortable list with PR title, repo path, status icon, comment count, last activity, reviewer avatars. Pagination.", @@ -2211,7 +2211,7 @@ { "id": "globalissues-001", "category": "feature", - "description": "Global issue dashboard at /issues — every issue the signed-in user opened, was assigned, or was mentioned in.", + "description": "Global issue dashboard at /issues \u2014 every issue the signed-in user opened, was assigned, or was mentioned in.", "page": "/issues", "priority": "P2", "ui_details": "Tab bar (Created / Assigned / Mentioned). Filter dropdowns (Open/Closed, repo, label, milestone, project). Sortable list with title, repo path, status icon, comment count, last activity. Pagination.", @@ -2262,7 +2262,7 @@ "priority": "P2", "ui_details": "Backend; visible in response headers. /rate_limit endpoint exposes current bucket state.", "behavior": "Per-token sliding-window limiter (per-IP for anon). Tiers: 5000/hr authenticated, 60/hr anon, 30/min for search. 403 on overage with body { error: { code: \"rate_limited\", message: ... } }. Versioning: server holds latest version; older clients pin via header.", - "data_model": "rate_limit_buckets(token_id|ip, resource, window_start, request_count) — Redis-equivalent or Postgres unlogged table.", + "data_model": "rate_limit_buckets(token_id|ip, resource, window_start, request_count) \u2014 Redis-equivalent or Postgres unlogged table.", "dependent_on": [ "auth-001", "infra-001" @@ -2289,7 +2289,7 @@ { "id": "gist-001", "category": "feature", - "description": "Gists — single-file or multi-file snippets at /gist/ with their own git history, public/secret visibility, fork/star, embeds, and revisions.", + "description": "Gists \u2014 single-file or multi-file snippets at /gist/ with their own git history, public/secret visibility, fork/star, embeds, and revisions.", "page": "/gist (your gists), /gist/new, /gist/, /gist//edit, /gist//revisions", "priority": "P3", "ui_details": "New-gist form with file tabs (+ add file), per-file language detection, Public vs Secret toggle, description input. View page with embed snippet (