From 2823d1f999f8239bef78ab1ca9d9d613b9a12bc7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 04:13:35 +0000 Subject: [PATCH] fix(delivery): admit PRs on any-language and fork-headed checkouts The project-open feedback cycle minted its provider seed from the first indexed `.rs` file, so every checkout without Rust failed its advisory mount with `feedback-code-index-rust-document-unavailable` and never reached pull-request discovery. The seed now takes any indexed document of the ready generation; only the cargo compiler publication provider still asks for a Rust document, and mounts only when one exists. The proximity drift check compares generation identity, which already pins the saved document, instead of the arbitrary seed file. Pull-request discovery used GitHub's commit-associated REST route, which answers `[]` for fork heads. It now queries the checkout repository's pull requests by head ref through GraphQL, pins the exact head commit, and resolves the head repository from `headRepositoryOwner` / `headRepository`. The daemon logs the typed discovery outcome. Before a generation mounts the cycle, the explicit advisory cycle names why none can: a disabled linked-worktree index or a checkout with no indexable source is a typed `unsupported` state, not "not mounted yet". --- .../fixtures/fork_head_pull_request.json | 50 ++ .../src/advisory/github_runtime/discovery.rs | 555 +++++++++++------- .../src/advisory/github_runtime/network.rs | 2 +- .../github_runtime/network/test_support.rs | 7 +- .../src/feedback/cycle_production.rs | 19 +- .../src/code_index_scheduler.rs | 1 + .../src/code_index_scheduler/registry.rs | 49 +- .../src/daemon/production_harness.rs | 3 + .../advisory_cycle_language_journey_test.rs | 137 +++++ .../project_open_owners/advisory_runtime.rs | 176 ++++-- 10 files changed, 730 insertions(+), 269 deletions(-) create mode 100644 crates/tracedecay-application/src/advisory/fixtures/fork_head_pull_request.json create mode 100644 crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs diff --git a/crates/tracedecay-application/src/advisory/fixtures/fork_head_pull_request.json b/crates/tracedecay-application/src/advisory/fixtures/fork_head_pull_request.json new file mode 100644 index 0000000000..af0f16bfe5 --- /dev/null +++ b/crates/tracedecay-application/src/advisory/fixtures/fork_head_pull_request.json @@ -0,0 +1,50 @@ +{ + "capture": { + "captured_at": "2026-09-26T01:40:00Z", + "pull_request": "https://github.com/dtolnay/anyhow/pull/463", + "note": "Fork-headed pull request: head sb123sb123/anyhow fix/462-new-with-backtrace, base dtolnay/anyhow master." + }, + "rest_commit_pulls": { + "url": "https://api.github.com/repos/dtolnay/anyhow/commits/c7b210a78b32ea90b10860c58983f8c0a742ed03/pulls", + "response": [] + }, + "graphql_head_ref": { + "url": "https://api.github.com/graphql", + "variables": { + "owner": "dtolnay", + "name": "anyhow", + "head": "fix/462-new-with-backtrace" + }, + "response": { + "data": { + "repository": { + "pullRequests": { + "pageInfo": { + "hasNextPage": false + }, + "nodes": [ + { + "databaseId": 4597599038, + "number": 463, + "headRefOid": "c7b210a78b32ea90b10860c58983f8c0a742ed03", + "baseRefOid": "c63b279f3f4af2b02ca6267d9eb47d6d10497f69", + "headRepositoryOwner": { + "login": "sb123sb123" + }, + "headRepository": { + "name": "anyhow" + }, + "baseRepository": { + "name": "anyhow", + "owner": { + "login": "dtolnay" + } + } + } + ] + } + } + } + } + } +} diff --git a/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs b/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs index f3b2c91201..202afd0f82 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/discovery.rs @@ -1,27 +1,47 @@ -use std::collections::BTreeSet; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use serde::Deserialize; +use serde_json::json; use tracedecay_domain::feedback::{GitHubPullRequestIdV1, GitHubReviewRateLimitCheckpointV1}; use tracedecay_domain::{CommitId, UtcMicros}; use url::Url; use super::dto::valid_full_git_oid; -use super::protocol::{ - GitHubLinkPageScopeV1, InvalidGitHubLinkContinuationV1, link_next_page, rate_limit_checkpoint, - retry_after_at, -}; +use super::protocol::{rate_limit_checkpoint, retry_after_at}; use super::{ GitHubHttpReadConfigV1, GitHubReadOnlyCredentialV1, GitHubReadPermissionV1, GitHubRepositoryTargetV1, }; const GITHUB_DISCOVERY_PAGE_SIZE_V1: usize = 100; -const MAX_GITHUB_DISCOVERY_PAGES_V1: u32 = 20; const MAX_GITHUB_DISCOVERY_RESPONSE_BYTES_V1: usize = 1024 * 1024; +/// Pull requests of the checkout's repository whose head branch has the +/// checkout's branch name, wherever that head lives. GitHub's +/// commit-associated pull-request routes only see heads pushed to the queried +/// repository, so a fork-headed pull request is found by its head ref and then +/// pinned by exact head commit. +const GITHUB_HEAD_REF_PULL_REQUESTS_QUERY_V1: &str = r" +query TraceDecayHeadRefPullRequests($owner: String!, $name: String!, $head: String!) { + repository(owner: $owner, name: $name) { + pullRequests(first: 100, headRefName: $head) { + pageInfo { hasNextPage } + nodes { + databaseId + number + headRefOid + baseRefOid + headRepositoryOwner { login } + headRepository { name } + baseRepository { name owner { login } } + } + } + } +} +"; + #[derive(Clone)] pub struct GitHubDiscoveryControlV1 { deadline: Instant, @@ -55,11 +75,14 @@ impl Drop for GitHubDiscoveryControlV1 { } /// One pull request whose provider head is exactly the requested immutable -/// commit. Repository identity comes from the fixed REST route, not response -/// prose or the caller's current branch. +/// commit. `target` is the repository the pull request is filed against (the +/// checkout's remote); the head repository is resolved from the pull request +/// itself and differs from it for a fork. #[derive(Clone, Debug, PartialEq, Eq)] pub struct GitHubExactCommitPullRequestV1 { pub target: GitHubRepositoryTargetV1, + pub head_repository_owner: String, + pub head_repository_name: String, pub base_commit_id: CommitId, pub head_commit_id: CommitId, } @@ -79,73 +102,129 @@ pub enum GitHubExactCommitDiscoveryOutcomeV1 { Unavailable, } -#[derive(Debug, Deserialize)] -struct AssociatedPullRequestV1 { - id: u64, +#[derive(Deserialize)] +struct HeadRefEnvelopeV1 { + data: Option, +} + +#[derive(Deserialize)] +struct HeadRefDataV1 { + repository: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HeadRefRepositoryV1 { + pull_requests: HeadRefConnectionV1, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HeadRefConnectionV1 { + page_info: HeadRefPageInfoV1, + nodes: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HeadRefPageInfoV1 { + has_next_page: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct HeadRefPullRequestV1 { + database_id: u64, number: u64, - base: AssociatedCommitRefV1, - head: AssociatedCommitRefV1, + head_ref_oid: String, + base_ref_oid: String, + head_repository_owner: Option, + head_repository: Option, + base_repository: Option, +} + +#[derive(Deserialize)] +struct HeadRefLoginV1 { + login: String, } -#[derive(Debug, Deserialize)] -struct AssociatedCommitRefV1 { - sha: String, - repo: Option, +#[derive(Deserialize)] +struct HeadRefNameV1 { + name: String, } -#[derive(Debug, Deserialize)] -struct AssociatedRepositoryV1 { - full_name: String, +#[derive(Deserialize)] +struct HeadRefBaseRepositoryV1 { + name: String, + owner: HeadRefLoginV1, } -/// Discovers the unique pull request whose head equals `head_commit`. +/// Discovers the unique pull request filed against `owner/repository` whose +/// head branch is `head_ref_name` at exactly `head_commit`, including heads +/// that live in a fork. /// -/// Acquisition is structurally limited to bounded `GET` requests against -/// GitHub's commit-associated pull-request endpoint. Anonymous acquisition is -/// used only for repositories this request proves publicly readable; private -/// acquisition requires a registered verified read credential. +/// Acquisition is one bounded static GraphQL query per scan; the scan result +/// must agree across scans before it is trusted. GitHub's GraphQL API refuses +/// unauthenticated reads, so an anonymous credential yields `Denied`. #[hotpath::measure(label = "usecases.github_network.discover_pr")] pub fn discover_exact_commit_pull_request_v1( owner: &str, repository: &str, + head_ref_name: &str, head_commit: &CommitId, config: &GitHubHttpReadConfigV1, credential: &GitHubReadOnlyCredentialV1, control: &GitHubDiscoveryControlV1, ) -> GitHubExactCommitDiscoveryOutcomeV1 { - let first = scan_exact_commit_pull_request_v1( - owner, - repository, - head_commit, + if !valid_graphql_uri(&config.graphql_uri) { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + } + let builder = ureq::Agent::config_builder() + .https_only(true) + .max_redirects(0) + .http_status_as_error(false); + let agent: ureq::Agent = super::instrument_github_ureq_agent(builder).build().into(); + discover_with_agent( + &agent, + &DiscoveryRequestV1 { + owner, + repository, + head_ref_name, + head_commit, + }, config, credential, control, - ); + ) +} + +struct DiscoveryRequestV1<'a> { + owner: &'a str, + repository: &'a str, + head_ref_name: &'a str, + head_commit: &'a CommitId, +} + +fn discover_with_agent( + agent: &ureq::Agent, + request: &DiscoveryRequestV1<'_>, + config: &GitHubHttpReadConfigV1, + credential: &GitHubReadOnlyCredentialV1, + control: &GitHubDiscoveryControlV1, +) -> GitHubExactCommitDiscoveryOutcomeV1 { + let scan = || scan_head_ref_pull_requests_v1(agent, request, config, credential, control); + let first = scan(); if !discovery_outcome_requires_consensus(&first) { return first; } - let second = scan_exact_commit_pull_request_v1( - owner, - repository, - head_commit, - config, - credential, - control, - ); + let second = scan(); if !discovery_outcome_requires_consensus(&second) { return second; } if let Some(agreed) = discovery_consensus(&first, &second, None) { return agreed; } - let third = scan_exact_commit_pull_request_v1( - owner, - repository, - head_commit, - config, - credential, - control, - ); + let third = scan(); if !discovery_outcome_requires_consensus(&third) { return third; } @@ -174,178 +253,128 @@ fn discovery_consensus( } } -fn scan_exact_commit_pull_request_v1( - owner: &str, - repository: &str, - head_commit: &CommitId, +fn scan_head_ref_pull_requests_v1( + agent: &ureq::Agent, + request: &DiscoveryRequestV1<'_>, config: &GitHubHttpReadConfigV1, credential: &GitHubReadOnlyCredentialV1, control: &GitHubDiscoveryControlV1, ) -> GitHubExactCommitDiscoveryOutcomeV1 { - let Some(_) = control.remaining() else { + let Some(remaining) = control.remaining() else { return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; }; - if !valid_path_segment(owner) - || !valid_path_segment(repository) - || !valid_full_git_oid(head_commit.as_str()) - || !valid_rest_base_uri(&config.rest_base_uri) - || config.request_timeout.is_zero() + let request_timeout = config.request_timeout.min(remaining); + if !valid_path_segment(request.owner) + || !valid_path_segment(request.repository) + || !valid_head_ref_name(request.head_ref_name) + || !valid_full_git_oid(request.head_commit.as_str()) + || request_timeout.is_zero() || config.connect_timeout.is_zero() || config.socket_timeout.is_zero() { return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; } - - let expected_repository = format!("{owner}/{repository}"); - let endpoint = format!( - "{}/repos/{owner}/{repository}/commits/{}/pulls", - config.rest_base_uri.trim_end_matches('/'), - head_commit.as_str() - ); - let mut page = 1_u32; - let mut visited_pages = BTreeSet::new(); - let mut matches = Vec::new(); - - loop { - let Some(remaining) = control.remaining() else { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + let authorization = + match credential.authorization_header_for(GitHubReadPermissionV1::PullRequests) { + Ok(authorization) => authorization, + Err(()) => return GitHubExactCommitDiscoveryOutcomeV1::Denied, }; - if page > MAX_GITHUB_DISCOVERY_PAGES_V1 || !visited_pages.insert(page) { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - } - let request_timeout = config.request_timeout.min(remaining); - if request_timeout.is_zero() { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - } - let builder = ureq::Agent::config_builder() - .timeout_global(Some(request_timeout)) - .timeout_connect(Some(config.connect_timeout.min(request_timeout))) - .timeout_recv_response(Some(config.socket_timeout.min(request_timeout))) - .timeout_recv_body(Some(config.socket_timeout.min(request_timeout))) - .https_only(true) - .max_redirects(0) - .http_status_as_error(false); - let agent: ureq::Agent = super::instrument_github_ureq_agent(builder).build().into(); - let authorization = - match credential.authorization_header_for(GitHubReadPermissionV1::PullRequests) { - Ok(authorization) => authorization, - Err(()) => return GitHubExactCommitDiscoveryOutcomeV1::Denied, + let mut post = agent + .post(&config.graphql_uri) + .config() + .timeout_global(Some(request_timeout)) + .timeout_connect(Some(config.connect_timeout.min(request_timeout))) + .timeout_recv_response(Some(config.socket_timeout.min(request_timeout))) + .timeout_recv_body(Some(config.socket_timeout.min(request_timeout))) + .build() + .header("Accept", "application/json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "tracedecay-github-read"); + if let Some(authorization) = authorization.as_ref() { + post = post.header("Authorization", authorization.as_str()); + } + let response = post.send_json(json!({ + "query": GITHUB_HEAD_REF_PULL_REQUESTS_QUERY_V1, + "variables": { + "owner": request.owner, + "name": request.repository, + "head": request.head_ref_name, + }, + })); + if control.remaining().is_none() { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + } + let Ok(mut response) = response else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; + if credential + .authorization_header_for(GitHubReadPermissionV1::PullRequests) + .is_err() + { + return GitHubExactCommitDiscoveryOutcomeV1::Denied; + } + let checkpoint = rate_limit_checkpoint(response.headers()); + match response.status().as_u16() { + 200 => {} + 401 => return GitHubExactCommitDiscoveryOutcomeV1::Denied, + 403 => { + let retry_at = retry_after_at(response.headers()); + if checkpoint + .as_ref() + .is_none_or(|checkpoint| checkpoint.remaining != 0) + && retry_at.is_none() + { + return GitHubExactCommitDiscoveryOutcomeV1::Denied; + } + return GitHubExactCommitDiscoveryOutcomeV1::RateLimited { + retry_at, + checkpoint, }; - let mut request = agent - .get(format!( - "{endpoint}?per_page={GITHUB_DISCOVERY_PAGE_SIZE_V1}&page={page}" - )) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", "tracedecay-github-read"); - if let Some(authorization) = authorization.as_ref() { - request = request.header("Authorization", authorization.as_str()); } - let response = request.call(); - if control.remaining().is_none() { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - } - let Ok(mut response) = response else { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - }; - if credential - .authorization_header_for(GitHubReadPermissionV1::PullRequests) - .is_err() - { - return GitHubExactCommitDiscoveryOutcomeV1::Denied; + 429 => { + return GitHubExactCommitDiscoveryOutcomeV1::RateLimited { + retry_at: retry_after_at(response.headers()), + checkpoint, + }; } - let checkpoint = rate_limit_checkpoint(response.headers()); - match response.status().as_u16() { - 200 => {} - 401 => return GitHubExactCommitDiscoveryOutcomeV1::Denied, - 403 => { - let retry_at = retry_after_at(response.headers()); - if checkpoint - .as_ref() - .is_none_or(|checkpoint| checkpoint.remaining != 0) - && retry_at.is_none() - { - return GitHubExactCommitDiscoveryOutcomeV1::Denied; - } - return GitHubExactCommitDiscoveryOutcomeV1::RateLimited { - retry_at, - checkpoint, - }; - } - 429 => { - return GitHubExactCommitDiscoveryOutcomeV1::RateLimited { - retry_at: retry_after_at(response.headers()), - checkpoint, - }; - } - 404 => return GitHubExactCommitDiscoveryOutcomeV1::NotFound, - _ => return GitHubExactCommitDiscoveryOutcomeV1::Unavailable, + _ => return GitHubExactCommitDiscoveryOutcomeV1::Unavailable, + } + let Ok(body) = response + .body_mut() + .with_config() + .limit(MAX_GITHUB_DISCOVERY_RESPONSE_BYTES_V1 as u64) + .read_to_vec() + else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; + let Ok(envelope) = serde_json::from_slice::(&body) else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; + let Some(data) = envelope.data else { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + }; + // GitHub answers a repository the credential cannot see as `null`. + let Some(repository) = data.repository else { + return GitHubExactCommitDiscoveryOutcomeV1::NotFound; + }; + let connection = repository.pull_requests; + if connection.page_info.has_next_page || connection.nodes.len() > GITHUB_DISCOVERY_PAGE_SIZE_V1 + { + return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; + } + let mut matches = Vec::new(); + for pull in connection.nodes { + if pull.head_ref_oid != request.head_commit.as_str() { + continue; } - - let mut next_page = match link_next_page( - response.headers(), - &GitHubLinkPageScopeV1 { - rest_base_uri: &config.rest_base_uri, - endpoint: &endpoint, - current_page: page, - page_size: GITHUB_DISCOVERY_PAGE_SIZE_V1, - }, - ) { - Ok(next_page) => next_page, - // Same fail-closed choice as releases and review/comment reads: a - // rejected Link header taints the exchange, so this page body is - // discarded. `link_next_page` traces the rejection. - Err(InvalidGitHubLinkContinuationV1) => { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - } - }; - let Ok(body) = response - .body_mut() - .with_config() - .limit(MAX_GITHUB_DISCOVERY_RESPONSE_BYTES_V1 as u64) - .read_to_vec() - else { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - }; - let Ok(pulls) = serde_json::from_slice::>(&body) else { + let Some(found) = exact_pull_request(request, pull) else { return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; }; - if pulls.len() > GITHUB_DISCOVERY_PAGE_SIZE_V1 { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - } - if pulls.len() == GITHUB_DISCOVERY_PAGE_SIZE_V1 && next_page.is_none() { - next_page = page.checked_add(1); - if next_page.is_none_or(|next| next > MAX_GITHUB_DISCOVERY_PAGES_V1) { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - } + matches.push(found); + if matches.len() > 1 { + return GitHubExactCommitDiscoveryOutcomeV1::Ambiguous; } - for pull in pulls { - if pull.head.sha != head_commit.as_str() { - continue; - } - if pull - .base - .repo - .as_ref() - .is_none_or(|repo| repo.full_name != expected_repository) - { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - } - let Some(found) = exact_pull_request(owner, repository, head_commit, pull) else { - return GitHubExactCommitDiscoveryOutcomeV1::Unavailable; - }; - matches.push(found); - if matches.len() > 1 { - return GitHubExactCommitDiscoveryOutcomeV1::Ambiguous; - } - } - - let Some(next_page) = next_page else { - break; - }; - page = next_page; } - matches .pop() .map_or(GitHubExactCommitDiscoveryOutcomeV1::NotFound, |pull| { @@ -354,35 +383,49 @@ fn scan_exact_commit_pull_request_v1( } fn exact_pull_request( - owner: &str, - repository: &str, - requested_head: &CommitId, - pull: AssociatedPullRequestV1, + request: &DiscoveryRequestV1<'_>, + pull: HeadRefPullRequestV1, ) -> Option { - if pull.id == 0 || pull.number == 0 || pull.head.sha != requested_head.as_str() { + let base_repository = pull.base_repository?; + if pull.database_id == 0 + || pull.number == 0 + || !base_repository + .owner + .login + .eq_ignore_ascii_case(request.owner) + || !base_repository + .name + .eq_ignore_ascii_case(request.repository) + { + return None; + } + let head_repository_owner = pull.head_repository_owner?.login; + let head_repository_name = pull.head_repository?.name; + if !valid_path_segment(&head_repository_owner) || !valid_path_segment(&head_repository_name) { return None; } - let base_commit_id = CommitId::new(pull.base.sha).ok()?; - let head_commit_id = CommitId::new(pull.head.sha).ok()?; + let base_commit_id = CommitId::new(pull.base_ref_oid).ok()?; + let head_commit_id = CommitId::new(pull.head_ref_oid).ok()?; if !valid_full_git_oid(base_commit_id.as_str()) || !valid_full_git_oid(head_commit_id.as_str()) { return None; } - let pull_request_id = GitHubPullRequestIdV1::new(pull.id.to_string()).ok()?; let target = GitHubRepositoryTargetV1 { - owner: owner.to_owned(), - repository: repository.to_owned(), + owner: request.owner.to_owned(), + repository: request.repository.to_owned(), pull_request_number: pull.number, - pull_request_id, + pull_request_id: GitHubPullRequestIdV1::new(pull.database_id.to_string()).ok()?, }; target.validate().then_some(GitHubExactCommitPullRequestV1 { target, + head_repository_owner, + head_repository_name, base_commit_id, head_commit_id, }) } -fn valid_rest_base_uri(value: &str) -> bool { +fn valid_graphql_uri(value: &str) -> bool { Url::parse(value).is_ok_and(|url| { url.scheme() == "https" && url.host_str().is_some() @@ -401,10 +444,27 @@ fn valid_path_segment(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } +/// A branch name as Git permits it in a ref, bounded; GraphQL carries it as a +/// variable, so this only rejects values no pull request head can have. +fn valid_head_ref_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 255 + && !value.starts_with('/') + && !value.ends_with('/') + && value + .bytes() + .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'~' | b'^' | b':' | b'\\')) +} + #[cfg(test)] mod tests { + use std::net::TcpListener; + + use super::super::network::test_support::{read_http_request_with_headers, write_http_json}; use super::*; + const FORK_HEAD_FIXTURE: &str = include_str!("../fixtures/fork_head_pull_request.json"); + fn found(number: u64, base: &str) -> GitHubExactCommitDiscoveryOutcomeV1 { GitHubExactCommitDiscoveryOutcomeV1::Found(GitHubExactCommitPullRequestV1 { target: GitHubRepositoryTargetV1 { @@ -413,11 +473,86 @@ mod tests { pull_request_number: number, pull_request_id: GitHubPullRequestIdV1::new(number.to_string()).unwrap(), }, + head_repository_owner: "owner".to_owned(), + head_repository_name: "repository".to_owned(), base_commit_id: CommitId::new(base).unwrap(), head_commit_id: CommitId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(), }) } + /// Serves the cached GitHub answers for dtolnay/anyhow#463 to `requests` + /// discovery requests: the commit-associated REST route answers `[]` for + /// a fork head, the head-ref GraphQL query names the pull request. + fn serve_fork_head_fixture(requests: usize) -> (String, std::thread::JoinHandle<()>) { + let fixture: serde_json::Value = serde_json::from_str(FORK_HEAD_FIXTURE).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + for _ in 0..requests { + let (mut stream, _) = listener.accept().unwrap(); + let (headers, _) = read_http_request_with_headers(&mut stream); + let response = if headers.starts_with("POST /graphql ") { + &fixture["graphql_head_ref"]["response"] + } else { + &fixture["rest_commit_pulls"]["response"] + }; + write_http_json(&mut stream, response); + } + }); + (format!("http://{address}"), server) + } + + fn discover_against(base_uri: &str, head_commit: &str) -> GitHubExactCommitDiscoveryOutcomeV1 { + let agent: ureq::Agent = ureq::Agent::config_builder() + .https_only(false) + .http_status_as_error(false) + .build() + .into(); + discover_with_agent( + &agent, + &DiscoveryRequestV1 { + owner: "dtolnay", + repository: "anyhow", + head_ref_name: "fix/462-new-with-backtrace", + head_commit: &CommitId::new(head_commit).unwrap(), + }, + &GitHubHttpReadConfigV1 { + rest_base_uri: base_uri.to_owned(), + graphql_uri: format!("{base_uri}/graphql"), + ..GitHubHttpReadConfigV1::default() + }, + &GitHubReadOnlyCredentialV1::anonymous(), + &GitHubDiscoveryControlV1::bounded(Instant::now() + Duration::from_secs(15)), + ) + } + + #[test] + fn fork_headed_pull_request_is_found_with_its_head_repository() { + let (base_uri, server) = serve_fork_head_fixture(4); + + assert_eq!( + discover_against(&base_uri, "c7b210a78b32ea90b10860c58983f8c0a742ed03"), + GitHubExactCommitDiscoveryOutcomeV1::Found(GitHubExactCommitPullRequestV1 { + target: GitHubRepositoryTargetV1 { + owner: "dtolnay".to_owned(), + repository: "anyhow".to_owned(), + pull_request_number: 463, + pull_request_id: GitHubPullRequestIdV1::new("4597599038").unwrap(), + }, + head_repository_owner: "sb123sb123".to_owned(), + head_repository_name: "anyhow".to_owned(), + base_commit_id: CommitId::new("c63b279f3f4af2b02ca6267d9eb47d6d10497f69").unwrap(), + head_commit_id: CommitId::new("c7b210a78b32ea90b10860c58983f8c0a742ed03").unwrap(), + }) + ); + assert_eq!( + discover_against(&base_uri, "0000000000000000000000000000000000000001"), + GitHubExactCommitDiscoveryOutcomeV1::NotFound, + "a local head the pull request no longer points at must not admit it" + ); + server.join().unwrap(); + } + #[test] fn exact_discovery_requires_two_agreeing_full_scans() { let scan = found(7, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); diff --git a/crates/tracedecay-application/src/advisory/github_runtime/network.rs b/crates/tracedecay-application/src/advisory/github_runtime/network.rs index 422145d278..68959a1503 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/network.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/network.rs @@ -31,7 +31,7 @@ use super::{ mod stack_network; #[cfg(test)] -mod test_support; +pub(super) mod test_support; pub const GITHUB_REVIEW_THREADS_QUERY_V1: &str = r" query TraceDecayGitHubReviewThreads( diff --git a/crates/tracedecay-application/src/advisory/github_runtime/network/test_support.rs b/crates/tracedecay-application/src/advisory/github_runtime/network/test_support.rs index 046cb2a08a..98d2303655 100644 --- a/crates/tracedecay-application/src/advisory/github_runtime/network/test_support.rs +++ b/crates/tracedecay-application/src/advisory/github_runtime/network/test_support.rs @@ -1,7 +1,7 @@ use std::io::{Read, Write}; use std::net::TcpStream; -pub(super) fn read_http_request_with_headers( +pub(in crate::advisory::github_runtime) fn read_http_request_with_headers( stream: &mut TcpStream, ) -> (String, serde_json::Value) { // macOS `accept` inherits `O_NONBLOCK` from a non-blocking listener, so @@ -49,7 +49,10 @@ pub(super) fn read_http_request(stream: &mut TcpStream) -> serde_json::Value { read_http_request_with_headers(stream).1 } -pub(super) fn write_http_json(stream: &mut TcpStream, value: &serde_json::Value) { +pub(in crate::advisory::github_runtime) fn write_http_json( + stream: &mut TcpStream, + value: &serde_json::Value, +) { let body = serde_json::to_vec(value).unwrap(); write!( stream, diff --git a/crates/tracedecay-application/src/feedback/cycle_production.rs b/crates/tracedecay-application/src/feedback/cycle_production.rs index abef1ba32b..d28b8f88fd 100644 --- a/crates/tracedecay-application/src/feedback/cycle_production.rs +++ b/crates/tracedecay-application/src/feedback/cycle_production.rs @@ -87,6 +87,9 @@ pub struct ProductionFeedbackCycleOpenV1 { pub project_runtime_db: RegisteredGlobalDbLeaseV1, pub runtime_state: Arc, pub provider_seed: ProductionFeedbackDocumentIdentityV1, + /// A Rust document of the same generation; `None` leaves the compiler + /// publication provider unmounted because nothing compiles with cargo. + pub compiler_seed: Option, pub document_identity: Arc, pub code_index_identity: Arc, @@ -292,8 +295,7 @@ fn require_current_saved_identity( .validate() .map_err(|_| LspRuntimeFailure::new("feedback-cycle-proximity-input"))?; let FeedbackContentIdentityV1::SavedContent { - generation_digest, - file_digest, + generation_digest, .. } = &input.request.content else { return Err(LspRuntimeFailure::new("feedback-cycle-proximity-overlay")); @@ -303,11 +305,10 @@ fn require_current_saved_identity( "feedback-cycle-proximity-generation", )); }; - if generation_id != ¤t.generation_id - || generation_digest != ¤t.generation_digest - || input.target.file != current.file - || file_digest != ¤t.file_digest()? - { + // The saved document was minted from its generation, and a generation is + // immutable, so an unchanged generation also pins that document's file and + // content identity; `current` is any document of the live generation. + if generation_id != ¤t.generation_id || generation_digest != ¤t.generation_digest { return Err(LspRuntimeFailure::new( "feedback-cycle-proximity-generation-drift", )); @@ -451,14 +452,14 @@ pub async fn resolve_production_feedback_cycle_parts( )?; let provider_seed = input.provider_seed; let mut provider_candidates = Vec::new(); - if provider_seed.language.as_str() == "rust" { + if let Some(compiler_seed) = &input.compiler_seed { provider_candidates.push(ProductionDiagnosticProviderCandidateV1::StoredPublication( compiler_diagnostic_candidate( &input.scope, &access_configuration_digest, &policy_digest, evaluated_at, - &provider_seed, + compiler_seed, )?, )); } diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs index dc118657e6..92ad9d8246 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler.rs @@ -212,6 +212,7 @@ pub use registry::{ CodeIndexOwnerActivityV1, CodeIndexWorkerPhaseV1, ScopedFeedbackDocumentIdentityV1, ServingGenerationInstallationOutcomeV1, ServingGenerationRollbackOutcomeV1, feedback_document_identity_from_generation, + feedback_language_document_identity_from_generation, }; pub type CodeIndexGenerationPublishedV1 = registry::CodeIndexGenerationPublishedV1; diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs index 7cc44fa14f..63eecef8de 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs @@ -27,7 +27,8 @@ use tracedecay_contracts::code_index_freshness::{ CodeGraphServingReadinessV1, CodeIndexBuildBlockedReasonV1, CodeIndexConvergenceParkedV1, }; use tracedecay_domain::{ - CodeGenerationId, ManifestDigest, ProjectId, RepositoryId, WorktreeId, host_cpu_target, + CodeGenerationId, ManifestDigest, ProjectId, RepositoryId, SanitizedCodeFileV1, WorktreeId, + host_cpu_target, }; use tracedecay_lsp::LspRuntimeFailure; @@ -3479,19 +3480,51 @@ pub fn feedback_document_identity_from_generation( .find(|file| file.logical_path == logical_path) .ok_or_else(|| LspRuntimeFailure::new("feedback-code-index-document-unavailable"))? } + // The seed only carries generation identity into provider identities + // that are re-keyed per saved document, so any indexed language serves. None => snapshot .files .iter() - .find(|file| { - Path::new(&file.logical_path) - .extension() - .and_then(|ext| ext.to_str()) - == Some("rs") - }) + .find(|file| file.language.is_some()) .ok_or_else(|| { - LspRuntimeFailure::new("feedback-code-index-rust-document-unavailable") + LspRuntimeFailure::new("feedback-code-index-generation-has-no-documents") })?, }; + feedback_document_identity_for_file(&generation, file) +} + +/// The first indexed document of `language` in an already-selected +/// generation; `Ok(None)` when the generation indexes none. +pub fn feedback_language_document_identity_from_generation( + generation: &LatestCodeTextGenerationV1, + language: &str, +) -> Result< + Option< + tracedecay_application::feedback::cycle_production::ProductionFeedbackDocumentIdentityV1, + >, + LspRuntimeFailure, +> { + generation + .metadata() + .snapshot() + .files + .iter() + .find(|file| { + file.language + .as_ref() + .is_some_and(|id| id.as_str() == language) + }) + .map(|file| feedback_document_identity_for_file(generation, file)) + .transpose() +} + +fn feedback_document_identity_for_file( + generation: &LatestCodeTextGenerationV1, + file: &SanitizedCodeFileV1, +) -> Result< + tracedecay_application::feedback::cycle_production::ProductionFeedbackDocumentIdentityV1, + LspRuntimeFailure, +> { let manifest = generation.metadata().manifest(); let generation_digest = ManifestDigest::new(manifest.snapshot_digest.as_str().to_owned()) .map_err(|_| LspRuntimeFailure::new("feedback-code-index-generation-invalid"))?; diff --git a/crates/tracedecay/src/daemon/production_harness.rs b/crates/tracedecay/src/daemon/production_harness.rs index 4f056b1c78..f44c0506bc 100644 --- a/crates/tracedecay/src/daemon/production_harness.rs +++ b/crates/tracedecay/src/daemon/production_harness.rs @@ -1586,6 +1586,9 @@ mod journey_test_support; #[cfg(test)] mod delivery_read_gate_journey_test; +#[cfg(test)] +mod advisory_cycle_language_journey_test; + #[cfg(test)] mod generation_retention_test; diff --git a/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs b/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs new file mode 100644 index 0000000000..10759b1994 --- /dev/null +++ b/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs @@ -0,0 +1,137 @@ +//! The advisory cycle that admits pull requests mounts on whatever languages +//! a checkout indexes. A TypeScript-only repository has a sealed generation +//! like any other, so the explicit cycle must run over it instead of staying +//! "not mounted yet" for the daemon's whole life. + +use std::path::Path; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use super::journey_test_support::{git, tool_answer}; +use super::*; + +const WARMING_CODE: &str = "feedback.advisory-cycle.unavailable"; + +/// Calls the explicit advisory cycle until it answers with anything other +/// than the retryable pre-mount state, or the bound expires. +async fn settled_advisory_cycle( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + document: &str, +) -> (bool, Value) { + let document_uri = url::Url::from_file_path(project.join(document)) + .expect("document uri") + .to_string(); + let deadline = Instant::now() + Duration::from_secs(90); + loop { + let response = harness + .call_tool( + project, + "tracedecay_feedback_advisory_cycle", + json!({"document_uri": document_uri, "format": "json"}), + ) + .await + .expect("advisory cycle call"); + let (refused, answer) = tool_answer(&response); + if !refused || answer["problem"]["code"] != json!(WARMING_CODE) { + return (refused, answer); + } + assert!( + Instant::now() < deadline, + "the advisory cycle stayed in its pre-mount state: {answer}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +#[hotpath::skip] +async fn typescript_only_checkout_runs_the_pull_request_advisory_cycle() { + let isolation = tempfile::TempDir::new().expect("production harness isolation"); + let project = isolation.path().join("project"); + std::fs::create_dir_all(project.join("src")).expect("project source dir"); + std::fs::write( + project.join("src/index.ts"), + "export function admitPullRequest(number: number): string {\n return `pr-${number}`;\n}\n", + ) + .expect("typescript source"); + git(&project, &["init", "--quiet", "-b", "feature/ts-admission"]); + git( + &project, + &[ + "remote", + "add", + "origin", + "https://github.com/tracedecay-fixture/typescript-admission.git", + ], + ); + git(&project, &["add", "."]); + git( + &project, + &["commit", "--quiet", "-m", "seed typescript admission"], + ); + + let harness = ProductionProjectCompositionHarnessV1::open(isolation.path(), [project.clone()]) + .await + .expect("production composition opens a TypeScript-only checkout"); + + let (refused, payload) = settled_advisory_cycle(&harness, &project, "src/index.ts").await; + assert!( + !refused, + "the cycle must run over the TypeScript generation: {payload}" + ); + assert_eq!( + payload["outcome"]["outcome"], + json!("evidence"), + "{payload}" + ); + let cycle = &payload["outcome"]["value"]["payload"]["cycle"]; + let producers: Vec<&Value> = cycle["advisory_provider_states"] + .as_array() + .unwrap_or_else(|| panic!("advisory provider states: {payload}")) + .iter() + .map(|state| &state["producer"]) + .collect(); + assert_eq!( + producers, + [ + &json!("git_hub_review"), + &json!("ci_localization"), + &json!("proximity") + ], + "{payload}" + ); + harness.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +#[hotpath::skip] +async fn checkout_without_indexable_source_names_why_the_advisory_cycle_cannot_run() { + let isolation = tempfile::TempDir::new().expect("production harness isolation"); + let project = isolation.path().join("project"); + std::fs::create_dir_all(&project).expect("project root"); + std::fs::write(project.join("LICENSE"), "MIT License\n").expect("license"); + git(&project, &["init", "--quiet", "-b", "main"]); + git(&project, &["add", "."]); + git(&project, &["commit", "--quiet", "-m", "seed license only"]); + + let harness = ProductionProjectCompositionHarnessV1::open(isolation.path(), [project.clone()]) + .await + .expect("production composition opens a checkout without indexable source"); + + let (refused, answer) = settled_advisory_cycle(&harness, &project, "LICENSE").await; + assert!( + refused, + "no generation exists to run a cycle over: {answer}" + ); + let problem = &answer["problem"]; + assert_eq!(problem["kind"], json!("unsupported"), "{answer}"); + assert_eq!( + problem["code"], + json!("feedback.advisory-cycle.no-indexable-source"), + "{answer}" + ); + assert_eq!(problem["legal_actions"], json!(["reconcile"]), "{answer}"); + harness.shutdown().await; +} diff --git a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs index 175ff6c840..9a0138698f 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs @@ -43,6 +43,8 @@ use tracedecay_application::feedback::{ }; use tracedecay_application::lsp_runtime::DaemonLspSessionFactory; use tracedecay_application::operation_stream::OperationKind; +use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; +use tracedecay_code_index_runtime::project_reads::code_index_disabled_for_scope; use tracedecay_contracts::context_scout::ContextScoutDeliveryOutcomeV1; use tracedecay_contracts::feedback::observations::{ FeedbackDeliveryRouteV1, FeedbackOperationV1, FeedbackOutcomeV1, FeedbackSourceEventV1, @@ -54,7 +56,7 @@ use tracedecay_contracts::feedback::{ }; use tracedecay_contracts::{ ApplicationProblem, CancellationContext, CapabilityGrantId, CapabilityGrantSnapshot, Deadline, - DisclosureClass, RequestContext, SafeDiagnostic, now_micros, + DisclosureClass, LegalAction, RequestContext, RetryDirective, SafeDiagnostic, now_micros, }; use tracedecay_domain::GitHeadStateV1; use tracedecay_domain::feedback::{ @@ -753,6 +755,11 @@ async fn refresh_feedback_cycle( .lock() .await .mounted_providers_for_files(&indexed_files); + let compiler_seed = + tracedecay_code_index_runtime::code_index_scheduler::feedback_language_document_identity_from_generation( + &indexed_generation, + "rust", + )?; let provider_seed = tracedecay_code_index_runtime::code_index_scheduler::feedback_document_identity_from_generation( indexed_generation, @@ -795,6 +802,7 @@ async fn refresh_feedback_cycle( project_runtime_db: producer.session_db.clone(), runtime_state, provider_seed, + compiler_seed, document_identity: Arc::new(document_identity), code_index_identity: Arc::new(producer.code_index_schedulers.clone()), test_attribution: Arc::new(producer.code_index_schedulers.clone()), @@ -1464,15 +1472,25 @@ async fn register_production_feedback_cycle( scope: state.scope.clone(), configuration: Arc::clone(state.graph.configuration_runtime()), }); + let provider_identity_failed = |error: LspRuntimeFailure| TraceDecayError::Config { + message: format!( + "project-open provider code-index identity failed: {}", + error.class() + ), + }; + let compiler_seed = + tracedecay_code_index_runtime::code_index_scheduler::feedback_language_document_identity_from_generation( + &indexed_generation, + "rust", + ) + .map_err(provider_identity_failed)?; let provider_seed = tracedecay_code_index_runtime::code_index_scheduler::feedback_document_identity_from_generation( indexed_generation, project_root, None, ) - .map_err(|error| TraceDecayError::Config { - message: format!("project-open provider code-index identity failed: {}", error.class()), - })?; + .map_err(provider_identity_failed)?; let document_identity = tracedecay_code_index_runtime::code_index_scheduler::ScopedFeedbackDocumentIdentityV1::new( invocation.code_index_schedulers.clone(), @@ -1492,6 +1510,7 @@ async fn register_production_feedback_cycle( project_runtime_db: state.session_db.clone(), runtime_state, provider_seed, + compiler_seed, document_identity: Arc::new(document_identity), code_index_identity: Arc::new(invocation.code_index_schedulers.clone()), test_attribution: Arc::new(invocation.code_index_schedulers.clone()), @@ -1988,6 +2007,7 @@ async fn register_project_proximity_read_authority( project_root: project_root.to_path_buf(), feedback_scope, proximity_read, + code_index_schedulers: invocation.code_index_schedulers.clone(), }) as Arc, ); invocation @@ -2006,8 +2026,8 @@ async fn register_project_proximity_read_authority( } /// Early proximity-only owner: serves `feedback_proximity` before the sealed -/// generation mounts the full advisory cycle. Advisory-cycle invocations stay -/// unavailable until that upgrade replaces this owner. +/// generation mounts the full advisory cycle. Advisory-cycle invocations name +/// why no generation can mount it until that upgrade replaces this owner. #[derive(Clone)] struct ProjectOpenProximityReadOwnerV1 { graph: Arc, @@ -2015,6 +2035,7 @@ struct ProjectOpenProximityReadOwnerV1 { project_root: std::path::PathBuf, feedback_scope: FeedbackScopeV1, proximity_read: FeedbackProximityReadRuntimeV1, + code_index_schedulers: CodeIndexSchedulerRegistryV1, } impl DaemonAdvisoryCycleInvocationPort for ProjectOpenProximityReadOwnerV1 { @@ -2023,9 +2044,36 @@ impl DaemonAdvisoryCycleInvocationPort for ProjectOpenProximityReadOwnerV1 { _request: DaemonAdvisoryCycleInvocationRequest, ) -> DaemonAdvisoryCycleInvocationFuture<'_> { Box::pin(async move { + let without_generation = + |code: &str, message: &str, legal_action| ApplicationProblem::Unsupported { + diagnostic: SafeDiagnostic { + code: code.to_owned(), + message: message.to_owned(), + }, + retry: RetryDirective::Never, + legal_actions: vec![legal_action], + }; + if code_index_disabled_for_scope(&self.code_index_schedulers, &self.scope) { + return Err(without_generation( + "feedback.advisory-cycle.code-index-disabled", + "The code index is disabled for this checkout, so no generation can mount the advisory feedback cycle", + LegalAction::CorrectRequest, + )); + } + if self + .code_index_schedulers + .reconciled_without_generation_for_scope(&self.scope) + .await + { + return Err(without_generation( + "feedback.advisory-cycle.no-indexable-source", + "The checkout has no indexable source files, so no generation can mount the advisory feedback cycle", + LegalAction::Reconcile, + )); + } Err(ApplicationProblem::unavailable(SafeDiagnostic { code: "feedback.advisory-cycle.unavailable".to_owned(), - message: "The advisory feedback cycle is not mounted yet".to_owned(), + message: "The advisory feedback cycle mounts once the first code-index generation is sealed".to_owned(), })) }) } @@ -2202,38 +2250,16 @@ async fn resolve_production_github_provider_config( let stack_observability = resolve_github_stack_observability(invocation, project_root, state, &owner, &repository) .await; - let authorization_context = - github_discovery_authorization_context(&state.access, feedback_scope); - let discovery_request = github_discovery_source_access_request(feedback_scope); - let head_commit_id = feedback_scope.head_commit_id.clone(); - let discovery_http = GitHubHttpReadConfigV1::default(); - let discovery_credential = credential.clone(); - let discovery = match authorization_context - .as_ref() - .zip(discovery_request.as_ref()) - { - Some((context, request)) - if source_access.authorize(context, request).await - == GitHubProviderLifecycleV1::Ready => - { - let control = - GitHubDiscoveryControlV1::bounded(Instant::now() + Duration::from_secs(15)); - let blocking_control = control.clone(); - tokio::task::spawn_blocking(move || { - discover_exact_commit_pull_request_v1( - &owner, - &repository, - &head_commit_id, - &discovery_http, - &discovery_credential, - &blocking_control, - ) - }) - .await - .ok() - } - _ => None, - }; + let discovery = discover_production_pull_request( + project_root, + state, + feedback_scope, + source_access.as_ref(), + &owner, + &repository, + &credential, + ) + .await; let github = match discovery { Some(GitHubExactCommitDiscoveryOutcomeV1::Found(pull)) => { let target = pull.target.clone(); @@ -2261,6 +2287,78 @@ async fn resolve_production_github_provider_config( }) } +/// Resolves the pull request whose head is this checkout's branch at its exact +/// commit, and records why when none is admitted. `None` means discovery was +/// not attempted: GitHub source access is not granted for this scope. +async fn discover_production_pull_request( + project_root: &Path, + state: &ProjectOpenDependentOwnerState, + feedback_scope: &FeedbackScopeV1, + source_access: &dyn GitHubSourceAccessAuthorityV1, + owner: &str, + repository: &str, + credential: &GitHubReadOnlyCredentialV1, +) -> Option { + let authorization_context = + github_discovery_authorization_context(&state.access, feedback_scope); + let discovery_request = github_discovery_source_access_request(feedback_scope); + let source_access_lifecycle = match authorization_context + .as_ref() + .zip(discovery_request.as_ref()) + { + Some((context, request)) => source_access.authorize(context, request).await, + None => GitHubProviderLifecycleV1::Denied, + }; + let head_ref_name = feedback_scope.branch_ref.strip_prefix("refs/heads/"); + let discovery = match (source_access_lifecycle, head_ref_name) { + (GitHubProviderLifecycleV1::Ready, Some(head_ref_name)) => { + let owner = owner.to_owned(); + let repository = repository.to_owned(); + let head_ref_name = head_ref_name.to_owned(); + let head_commit_id = feedback_scope.head_commit_id.clone(); + let credential = credential.clone(); + let control = + GitHubDiscoveryControlV1::bounded(Instant::now() + Duration::from_secs(15)); + let blocking_control = control.clone(); + tokio::task::spawn_blocking(move || { + discover_exact_commit_pull_request_v1( + &owner, + &repository, + &head_ref_name, + &head_commit_id, + &GitHubHttpReadConfigV1::default(), + &credential, + &blocking_control, + ) + }) + .await + .ok() + } + _ => None, + }; + match &discovery { + Some(GitHubExactCommitDiscoveryOutcomeV1::Found(pull)) => tracing::info!( + event = "github_pull_request_discovery", + outcome = "found", + project = %project_root.display(), + pull_request = pull.target.pull_request_number, + head_repository = %format!("{}/{}", pull.head_repository_owner, pull.head_repository_name), + ), + Some(outcome) => tracing::info!( + event = "github_pull_request_discovery", + outcome = ?outcome, + project = %project_root.display(), + ), + None => tracing::info!( + event = "github_pull_request_discovery", + outcome = "not_attempted", + source_access = ?source_access_lifecycle, + project = %project_root.display(), + ), + } + discovery +} + /// Mounts the canonical Observatory lane for GitHub stack observations. /// Telemetry mounting failure is logged and yields `None`, the review /// refresh owner keeps its product path either way.