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
6 changes: 6 additions & 0 deletions build-progress.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10786,3 +10786,9 @@ Blockers: full make test Cargo passed but broad web Vitest still has unrelated u
Status: QA PASS. Fresh controller recovery accepted for Network/Forks and ready for main merge.
Evidence: make doctor healthy; make check passed; DB-backed package_detail_contract 4/4, projects_list_contract 1/1, and repository_network_contract 1/1 passed against localhost:55433; focused Network/Forks/API docs Vitest passed 9/9; focused system-Chrome repository-network Playwright passed setup + flow 2/2 in 21.0s.
Fixes accepted: package settings camelCase serde IDs, project copy FOR UPDATE OF projects + workflow_key clone + org membership/base-role guard, corrected projects default-open expectation, repository_network_contract rate-limit identity isolation, and repository-network E2E now uses shared auth plus container-backed psql fallback instead of host psql.

2026-05-24 - ai-001 QA/fix final lane
- make doctor passed after restarting postgres-test; host localhost:55433 became reachable.
- Fixed /api/ai/* routes to require authenticated users before AI content/cache access, aligning per-user settings/rate-limit/privacy requirements.
- Added DB-backed ai-001 contract coverage for auth, opt-in, provider-missing, cache hit/miss, PR files/reviewers/inline seed, and changelog previousTag/targetTag cache key. Focused AI API tests passed (3/3).
- Focused AI UI unit tests passed (3 files, 61 tests). make check passed. make test remains red from unrelated web unit timeouts; make test-e2e is blocked by missing/unsupported Playwright Chromium on ubuntu26.04-x64, so ai-001 qa_pass remains false.
41 changes: 14 additions & 27 deletions crates/api/src/routes/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ async fn repo_summary(
Query(query): Query<AiQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorEnvelope>)> {
let pool = state.db.as_ref().ok_or_else(database_unavailable)?;
let actor = AuthenticatedUser::optional_from_headers(&state, &headers).await?;
let actor = AuthenticatedUser::from_headers(&state, &headers).await?.0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update AI docs for required authentication

This switches the repository AI endpoint from optional to mandatory auth, and the same pattern is repeated for the PR and changelog handlers below, but the user-facing API catalog still says auth is “Optional signed opengithub session cookie” for all three AI endpoints (web/src/lib/api-docs.ts:96, web/src/lib/api-docs.ts:120, web/src/lib/api-docs.ts:139). Clients following /docs/api for public repositories will now make unauthenticated calls and receive 401 not_authenticated, so the docs/examples should be updated to describe the required session/PAT contract.

Useful? React with 👍 / 👎.

let summary = repository_ai_summary(
pool,
&owner,
&repo,
actor.as_ref().map(|user| user.id),
Some(actor.id),
query.regenerate.unwrap_or(false),
)
.await
Expand All @@ -64,16 +64,10 @@ async fn regenerate_repo_summary(
Path((owner, repo)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorEnvelope>)> {
let pool = state.db.as_ref().ok_or_else(database_unavailable)?;
let actor = AuthenticatedUser::optional_from_headers(&state, &headers).await?;
let summary = repository_ai_summary(
pool,
&owner,
&repo,
actor.as_ref().map(|user| user.id),
true,
)
.await
.map_err(map_ai_error)?;
let actor = AuthenticatedUser::from_headers(&state, &headers).await?.0;
let summary = repository_ai_summary(pool, &owner, &repo, Some(actor.id), true)
.await
.map_err(map_ai_error)?;
Ok(Json(json!(summary)))
}

Expand All @@ -84,13 +78,13 @@ async fn pr_summary(
Query(query): Query<AiQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorEnvelope>)> {
let pool = state.db.as_ref().ok_or_else(database_unavailable)?;
let actor = AuthenticatedUser::optional_from_headers(&state, &headers).await?;
let actor = AuthenticatedUser::from_headers(&state, &headers).await?.0;
let summary = pull_request_ai_summary(
pool,
&owner,
&repo,
number,
actor.as_ref().map(|user| user.id),
Some(actor.id),
query.regenerate.unwrap_or(false),
)
.await
Expand All @@ -104,17 +98,10 @@ async fn regenerate_pr_summary(
Path((owner, repo, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorEnvelope>)> {
let pool = state.db.as_ref().ok_or_else(database_unavailable)?;
let actor = AuthenticatedUser::optional_from_headers(&state, &headers).await?;
let summary = pull_request_ai_summary(
pool,
&owner,
&repo,
number,
actor.as_ref().map(|user| user.id),
true,
)
.await
.map_err(map_ai_error)?;
let actor = AuthenticatedUser::from_headers(&state, &headers).await?.0;
let summary = pull_request_ai_summary(pool, &owner, &repo, number, Some(actor.id), true)
.await
.map_err(map_ai_error)?;
Ok(Json(json!(summary)))
}

Expand All @@ -126,12 +113,12 @@ async fn changelog(
RestJson(request): RestJson<AiChangelogRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorEnvelope>)> {
let pool = state.db.as_ref().ok_or_else(database_unavailable)?;
let actor = AuthenticatedUser::optional_from_headers(&state, &headers).await?;
let actor = AuthenticatedUser::from_headers(&state, &headers).await?.0;
let changelog = ai_changelog(
pool,
&owner,
&repo,
actor.as_ref().map(|user| user.id),
Some(actor.id),
request,
query.regenerate.unwrap_or(false),
)
Expand Down
191 changes: 190 additions & 1 deletion crates/api/tests/api_ai_surfaces_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use uuid::Uuid;
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");

async fn database_pool() -> Option<PgPool> {
let _ = dotenvy::from_filename(".env.test");
let database_url = std::env::var("TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.ok()
Expand Down Expand Up @@ -250,7 +251,7 @@ async fn repository_ai_summary_respects_repository_and_user_opt_in() {
let (status, body) = send_json(
app.clone(),
&format!("/api/ai/repos/{owner_login}/{public_repo}/summary"),
None,
Some(&reader_cookie),
)
.await;
assert_eq!(status, StatusCode::OK);
Expand Down Expand Up @@ -384,3 +385,191 @@ async fn ai_changelog_uses_previous_and_target_tag_range_for_cache_key() {
"### Added\n- Generated from only the selected tag range."
);
}

async fn seed_pull_request(pool: &PgPool, repository_id: Uuid, author_id: Uuid) -> (Uuid, i64) {
let issue_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO issues (repository_id, number, title, body, state, author_user_id)
VALUES ($1, 42, 'Improve AI surfaces', 'body', 'open', $2)
RETURNING id
"#,
)
.bind(repository_id)
.bind(author_id)
.fetch_one(pool)
.await
.expect("issue should persist");
let pull_request_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO pull_requests (repository_id, issue_id, number, title, body, author_user_id, head_ref, base_ref, head_repository_id, base_repository_id)
VALUES ($1, $2, 42, 'Improve AI surfaces', 'Adds AI summary UI', $3, 'ai', 'main', $1, $1)
RETURNING id
"#,
)
.bind(repository_id)
.bind(issue_id)
.bind(author_id)
.fetch_one(pool)
.await
.expect("pull request should persist");
let (commit_id, _) = seed_commit(pool, repository_id, author_id, "Wire PR AI summary", 1).await;
sqlx::query("INSERT INTO pull_request_commits (pull_request_id, commit_id, position) VALUES ($1, $2, 0)")
.bind(pull_request_id)
.bind(commit_id)
.execute(pool)
.await
.expect("pull commit should persist");
sqlx::query(
r#"
INSERT INTO pull_request_files (pull_request_id, path, status, additions, deletions, byte_size)
VALUES ($1, 'web/src/components/AiSummary.tsx', 'added', 80, 3, 4096)
"#,
)
.bind(pull_request_id)
.execute(pool)
.await
.expect("pull file should persist");
(pull_request_id, 42)
}

#[tokio::test]
async fn ai_endpoints_require_auth_and_return_cached_shapes_without_provider() {
let Some(pool) = database_pool().await else {
panic!("ai-001 contract requires TEST_DATABASE_URL/DATABASE_URL and reachable Postgres");
};

std::env::remove_var("OPENAI_API_KEY");
let config = app_config();
let owner = create_user(&pool, "ai-shape-owner").await;
let owner_login = owner.username.as_deref().unwrap_or(&owner.email);
let owner_cookie = cookie_header(&pool, &config, &owner).await;
let repo_name = create_repo(&pool, &owner, RepositoryVisibility::Public, true).await;
let repository_id = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM repositories WHERE owner_user_id = $1 AND name = $2",
)
.bind(owner.id)
.bind(&repo_name)
.fetch_one(&pool)
.await
.expect("repository should exist");

let (repo_commit_id, _) = seed_commit(
&pool,
repository_id,
owner.id,
"Summarize repository with AI",
1,
)
.await;
sqlx::query("INSERT INTO repository_files (repository_id, commit_id, oid, path, content, byte_size) VALUES ($1, $2, '1111111111111111111111111111111111111111', 'README.md', 'AI repo readme', 14)")
.bind(repository_id)
.bind(repo_commit_id)
.execute(&pool)
.await
.expect("repository file should persist");
let repo_context = "Repository: ".to_owned() + owner_login + "/" + &repo_name + "\nDescription: AI surface contract repository\nFiles:\n## README.md\nAI repo readme\nRecent commits:\nSummarize repository with AI";
let repo_hash = hash_content(&repo_context);
sqlx::query(
r#"
INSERT INTO ai_outputs (kind, scope_type, scope_id, content_hash, prompt_version, model, output, created_by_user_id)
VALUES ('repo_summary', 'repository', $1, $2, 'ai-001-v1', 'gpt-4o-mini', '- Cached repository summary', $3)
"#,
)
.bind(repository_id)
.bind(repo_hash)
.bind(owner.id)
.execute(&pool)
.await
.expect("repo cache should persist");

let (pull_request_id, pr_number) = seed_pull_request(&pool, repository_id, owner.id).await;
let pr_context = "Title: Improve AI surfaces\nBody: Adds AI summary UI\nBase: main\nHead: ai\nFiles:\n- added web/src/components/AiSummary.tsx +80 -3\nCommits:\nWire PR AI summary";
let pr_hash = hash_content(pr_context);
sqlx::query(
r#"
INSERT INTO ai_outputs (kind, scope_type, scope_id, content_hash, prompt_version, model, output, created_by_user_id)
VALUES ('pr_summary', 'pull_request', $1, $2, 'ai-001-v1', 'gpt-4o', 'TL;DR cached PR summary', $3)
"#,
)
.bind(pull_request_id)
.bind(pr_hash)
.bind(owner.id)
.execute(&pool)
.await
.expect("pr cache should persist");

let (previous_commit_id, _) =
seed_commit(&pool, repository_id, owner.id, "Previous release", 4).await;
let (target_commit_id, target_oid) =
seed_commit(&pool, repository_id, owner.id, "Ship AI changelog", 2).await;
seed_release(&pool, repository_id, owner.id, "v1.0.0", previous_commit_id).await;
let release_id = seed_release(&pool, repository_id, owner.id, "v1.1.0", target_commit_id).await;
let changelog_context = format!("{target_oid} Ship AI changelog");
let changelog_hash = hash_content(&format!("{}:{}:{changelog_context}", "v1.0.0", "v1.1.0"));
sqlx::query(
r#"
INSERT INTO ai_outputs (kind, scope_type, scope_id, content_hash, prompt_version, model, output, created_by_user_id)
VALUES ('changelog', 'release', $1, $2, 'ai-001-v1', 'gpt-4o', '### Added\n- Cached changelog', $3)
"#,
)
.bind(release_id)
.bind(changelog_hash)
.bind(owner.id)
.execute(&pool)
.await
.expect("changelog cache should persist");

let app = opengithub_api::build_app_with_config(Some(pool.clone()), config);
let (status, body) = send_json(
app.clone(),
&format!("/api/ai/repos/{owner_login}/{repo_name}/summary"),
None,
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["error"]["code"], "not_authenticated");

let (status, body) = send_json(
app.clone(),
&format!("/api/ai/repos/{owner_login}/{repo_name}/summary"),
Some(&owner_cookie),
)
.await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body["error"]["code"], "ai_provider_not_configured");

let (status, body) = post_json(
app.clone(),
&format!("/api/ai/repos/{owner_login}/{repo_name}/summary"),
Some(&owner_cookie),
json!({}),
)
.await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body["error"]["code"], "ai_provider_not_configured");

let (status, body) = send_json(
app.clone(),
&format!("/api/ai/repos/{owner_login}/{repo_name}/pulls/{pr_number}/summary"),
Some(&owner_cookie),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["output"]["cached"], true);
assert_eq!(
body["filesOfInterest"][0]["path"],
"web/src/components/AiSummary.tsx"
);
assert!(body["inlineCommentSeed"].as_str().is_some());

let (status, body) = post_json(
app,
&format!("/api/ai/repos/{owner_login}/{repo_name}/releases/changelog"),
Some(&owner_cookie),
json!({ "previousTag": "v1.0.0", "targetTag": "v1.1.0" }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["output"]["cached"], true);
assert_eq!(body["output"]["output"], "### Added\\n- Cached changelog");
}
14 changes: 7 additions & 7 deletions qa-hints.json
Original file line number Diff line number Diff line change
Expand Up @@ -8068,16 +8068,16 @@
{
"feature_id": "ai-001",
"tests_written": [
"DB-backed AI API contract for auth, opt-in, cache hit/miss/provider-missing, PR summary, changelog tag range",
"repository AI summary card renders cached output and regenerate POST action",
"pull request AI brief renders TL;DR, files of interest, suggested reviewers, and regenerate POST action",
"release detail renders AI changelog output and generate changelog POST action",
"API docs list repository summary, PR summary, and release changelog AI endpoints"
"pull request AI brief renders TL;DR, files of interest, suggested reviewers, author-only inline seed, and regenerate POST action",
"release detail renders AI changelog output and generate changelog POST action"
],
"needs_deeper_qa": [
"Live OpenAI provider behavior with OPENAI_API_KEY, prompt quality, and provider failure handling",
"Private repository opt-in enforcement against real private repositories and user AI setting toggles",
"Rate-limit bucket behavior for repeated AI generation under real signed sessions",
"Full signed-session E2E after local TEST_DATABASE_URL credentials are healthy"
"Run full make test after unrelated api-docs and repository-code-scanning-alerts-page vitest timeouts are fixed or deflaked",
"Run Playwright E2E and axe/a11y scans once a supported Chromium browser is available for ubuntu26.04-x64 or the runner OS is supported",
"Live OpenAI provider behavior with a real OPENAI_API_KEY and prompt quality review",
"Repeated signed-session rate-limit exhaustion probe through a browser/API scenario"
]
},
{
Expand Down
Loading