diff --git a/.github/workflows/open-pr-queue-live.yml b/.github/workflows/open-pr-queue-live.yml new file mode 100644 index 000000000..60e64bf9a --- /dev/null +++ b/.github/workflows/open-pr-queue-live.yml @@ -0,0 +1,49 @@ +name: open-pr-queue-live + +on: + push: + branches: + - docs/bandscope-product-readiness-baseline + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + checks: read + statuses: read + +jobs: + refresh-live-queue: + name: queue / live-refresh-contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Refresh from bounded live GitHub authority + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/checks/refresh_open_pr_queue.py + - name: Validate machine queue without network authority + run: python3 scripts/checks/verify_open_pr_queue.py + - name: Collect exact-head check, review, and thread readiness + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/checks/collect_open_pr_readiness.py + - name: Validate exact-head readiness without network authority + run: python3 scripts/checks/verify_open_pr_readiness.py + - name: Render deterministic human queue + run: python3 scripts/checks/render_open_pr_queue.py + - name: Verify machine/human projection parity + run: python3 scripts/checks/render_open_pr_queue.py --check + - name: Preserve exact-head live queue evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: open-pr-queue-${{ github.sha }} + path: | + docs/product-readiness/open-pr-queue.json + docs/product-readiness/open-pr-queue.md + docs/product-readiness/open-pr-readiness.json + if-no-files-found: error + retention-days: 7 diff --git a/docs/architecture/decisions/0001-bandscope-1-0-product-readiness.md b/docs/architecture/decisions/0001-bandscope-1-0-product-readiness.md new file mode 100644 index 000000000..2313583a4 --- /dev/null +++ b/docs/architecture/decisions/0001-bandscope-1-0-product-readiness.md @@ -0,0 +1,167 @@ +# ADR-0001: BandScope 1.0 Product-Readiness Program and Merge Trains + +- **Status:** Proposed +- **Date:** 2026-08-20 +- **Decision owners:** BandScope maintainers +- **Program issue:** [#958](https://github.com/ContextualWisdomLab/bandscope/issues/958) +- **Figma file ID:** `zthWmqfNKUgJBECvv002Qk` + +## Context + +BandScope has grown through many narrow, independently reviewable pull requests. The 2026-08-20 snapshot contains 83 open PRs above protected `develop@acdbea6344fe1231c39535b575f4de35e4c607c9`. + +The repository already contains valuable local-first architecture, scientific-analysis, security, accessibility, and rehearsal-workspace work. The queue nevertheless lacks a single commercial completion sequence. Several boundaries have many small writers while other buyer-critical boundaries—trusted distribution, active playback, durable projects, support evidence, and licensed activation—have no complete protected vertical. + +A large number of individually plausible PRs can make delivery less reliable when: + +- several heads carry unrelated dependency/toolchain changes; +- adjacent UI slices create independent state or action stores; +- reviews and required checks are bound to predecessor heads; +- protected-base failures are copied into leaf PRs; +- a closed or squash-merged predecessor changes the successor's ancestry; +- design states are represented before runtime behavior exists; +- a validation package is described as a release without signing, notarization, updater, or rollback evidence. + +The saved Figma file is useful but also demonstrates the need for source parity. Its cover describes a 28-page design-system plan while the current file exposes two top-level pages. Its footer and the reviewed repository/Tauri package metadata all cite version `0.1.3`; the remaining parity risk is planned or unimplemented design state rather than a current version-label mismatch. + +## Decision + +### 1. Preserve the product boundary + +BandScope remains a **local-first rehearsal decision tool**. + +It will: + +- analyze user-authorized local audio; +- explain evidence, confidence, and limitations; +- turn analysis into concrete rehearsal actions; +- provide an active, accessible passage-rehearsal player; +- preserve durable local projects and bounded handoffs; +- distribute trusted desktop artifacts and support evidence. + +It will not become a DAW, notation editor, plugin host, mandatory cloud account, or unbounded general-purpose media/file processor. + +### 2. Use one completion program + +Issue #958 is the parent product-completion program. The following child issues own independent buyer boundaries: + +- #960 — trusted release, updater, and rollback; +- #961 — active rehearsal player; +- #962 — project format, autosave, migration, and recovery; +- #963 — diagnostics and offline support bundle; +- #964 — licensed demo and first-run activation; +- #965 — Figma, Storybook, shipped UI, and WCAG parity; +- #966 — dependency-aware PR convergence. + +Existing canonical accuracy, resource, handoff, supply-chain, and platform issues remain authoritative where their responsibility already exists. + +### 3. Process work as dependency-aware merge trains + +Each reviewed open PR must belong to exactly one initial train: + +- `T0` dependency, toolchain, workflow, and quality base; +- `T1` local input, filesystem authority, resource admission, and cancellation; +- `T2` scientific accuracy, MIR evaluation, and numerical parity; +- `T3` rehearsal actions and active-player vertical; +- `T4` project portability, handoff, and interoperability; +- `T5` activation, UI system, Storybook, and accessibility; +- `T6` diagnostics, redaction, security evidence, and supportability; +- `T7` signed commercial release and updater. + +A live refresh may place newly discovered PRs in `T8`, a **triage-only temporary train** owned by issue #966. `T8` is inventory evidence, not an actionable merge train: every `T8` PR must be reviewed and reclassified to exactly one of `T0`–`T7` before any source/ref mutation, merge-readiness decision, auto-merge, or merge action is taken for that PR. + +The initial routing is an investigation aid. Issue #966 must refresh the live exact head, checks, reviews, unresolved threads, dependencies, and succession before any action. + +### 4. Enforce one canonical writer per boundary + +A product/file boundary may have multiple active PRs only when their stack order is declared. Otherwise maintainers must select one canonical PR, transfer unique requirements/tests, and close duplicates or superseded heads with succession evidence. + +No feature PR may carry an unrelated dependency, lockfile, workflow, or toolchain baseline. Those changes belong to `T0` and are inherited only after protected integration. + +### 5. Treat exact-head evidence as non-transferable + +A PR may merge only when its unchanged exact current head has: + +- every required repository and central check in terminal success; +- qualifying independent non-author approval that covers the last push; +- zero unresolved actionable review threads; +- branch-protection acceptance without bypass; +- required repository-owned production statement and branch coverage; +- required public API documentation evidence. + +Queued, skipped-required, failed inherited-base, predecessor-head, protected-base, model-only, self/author, or administrative-bypass evidence is not success. + +### 6. Define design authority explicitly + +- Runtime behavior, semantics, localization keys, and version identity originate in the repository. +- Design tokens are versioned in code and synchronized to Figma variables. +- Storybook is the executable component/state inventory. +- Figma file `zthWmqfNKUgJBECvv002Qk` is the reviewed visual/interaction specification. +- The shipped Tauri application is the final acceptance target. +- A Figma or Storybook state that is not implemented must be marked proposed rather than complete. + +### 7. Release only after measurable product verticals + +A release candidate is not created merely because packaging succeeds. The protected release commit must carry: + +- real decoded-audio MIR acceptance and claim boundaries; +- bounded resource and cancellation evidence; +- active-player acceptance; +- project migration and recovery evidence; +- end-to-end accessibility evidence; +- support-bundle/privacy evidence; +- signed Windows artifacts; +- signed and notarized macOS artifacts; +- signature-verified updater and rollback evidence; +- checksums, SBOM, and build provenance. + +## Consequences + +### Positive + +- Buyers can evaluate a coherent rehearsal workflow rather than isolated features. +- Maintainers can make explicit succession decisions and reduce review drift. +- Scientific, accessibility, security, release, and design evidence are bound to the same product version. +- Dependency and workflow changes stop contaminating unrelated feature heads. +- Waiting for one external review lane does not stop independent productive work. + +### Costs + +- Some existing PRs will be restacked, reconstructed, superseded, or closed. +- Exact-head verification must be repeated after ancestry changes. +- The initial queue manifest requires continuing maintenance. +- Design work may be revised when it does not match implemented contracts. +- A 1.0 release is later than an unsigned or evidence-light package, but materially more trustworthy. + +## Rejected alternatives + +### Merge every open PR in numerical order + +Rejected because PR number is not dependency order, and merging all work can preserve duplicate stores, stale architecture, and conflicting dependency baselines. + +### Close most PRs solely to reduce the count + +Rejected because unique tests, security findings, and product requirements could be lost. Closure requires explicit succession evidence. + +### Treat the Figma file as the product source of truth + +Rejected because the current file contains planned or unimplemented pages. Behavior, semantics, and version identity must remain executable and testable in code. + +### Release unsigned artifacts and add trust later + +Rejected because signing, notarization, updater verification, project compatibility, and rollback are part of the commercial distribution contract. + +### Expand into a DAW + +Rejected because recording, plugin hosting, free-form mixing, and composition would dilute the buyer problem and multiply real-time/audio-platform risk. + +## Review triggers + +This ADR must be revisited when: + +- the product boundary changes beyond rehearsal decision/support; +- BandScope adds a mandatory cloud account or remote project storage; +- the Figma source-of-truth model changes; +- the PR queue no longer needs train-based governance; +- the application adopts a different desktop/runtime distribution architecture; +- a project format or updater change makes rollback guarantees materially different. \ No newline at end of file diff --git a/docs/doctoring/PRODUCT_READINESS_REFERENCES.md b/docs/doctoring/PRODUCT_READINESS_REFERENCES.md new file mode 100644 index 000000000..1d0c3fa8d --- /dev/null +++ b/docs/doctoring/PRODUCT_READINESS_REFERENCES.md @@ -0,0 +1,48 @@ +# Product Readiness References + +This bibliography supports the BandScope 1.0 product-readiness baseline and related issues. Citations use APA 7th style where the source provides sufficient publication metadata. Product documentation pages are cited as organizational web resources and must be rechecked when their upstream version changes. + +## Desktop distribution and update + +Apple Inc. (n.d.). *Notarizing macOS software before distribution*. Apple Developer Documentation. https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution + +Microsoft. (n.d.). *Sign an app package using SignTool*. Microsoft Learn. https://learn.microsoft.com/windows/msix/package/sign-app-package-using-signtool + +Tauri Contributors. (n.d.). *Application signing*. Tauri. https://v2.tauri.app/distribute/sign/ + +Tauri Contributors. (n.d.). *Updater plugin*. Tauri. https://v2.tauri.app/plugin/updater/ + +## Accessibility and executable design evidence + +Storybook Contributors. (n.d.). *Accessibility testing*. Storybook. https://storybook.js.org/docs/writing-tests/accessibility-testing + +World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (2023). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ + +## Music-information-retrieval evaluation + +Bittner, R. M., Salamon, J., Tierney, M., Mauch, M., Cannam, C., & Bello, J. P. (2014). MedleyDB: A multitrack dataset for annotation-intensive MIR research. In *Proceedings of the 15th International Society for Music Information Retrieval Conference* (pp. 155–160). https://medleydb.weebly.com/ + +MIREX. (n.d.). *Audio chord estimation*. Music Information Retrieval Evaluation eXchange. https://www.music-ir.org/mirex/wiki/2024:Audio_Chord_Estimation + +Raffel, C., McFee, B., Humphrey, E. J., Salamon, J., Nieto, O., Liang, D., & Ellis, D. P. W. (2014). mir_eval: A transparent implementation of common MIR metrics. In *Proceedings of the 15th International Society for Music Information Retrieval Conference* (pp. 367–372). https://craffel.github.io/mir_eval/ + +Rafii, Z., Liutkus, A., Stöter, F.-R., Mimilakis, S. I., & Bittner, R. (2019). MUSDB18-HQ—An uncompressed version of MUSDB18. *Zenodo*. https://doi.org/10.5281/zenodo.3338373 + +Stöter, F.-R., Liutkus, A., & Ito, N. (2018). The 2018 Signal Separation Evaluation Campaign. In E. Vincent, A. Yeredor, Z. Koldovský, & P. Tichavský (Eds.), *Latent Variable Analysis and Signal Separation* (pp. 293–305). Springer. https://doi.org/10.1007/978-3-319-93764-9_28 + +## Use in BandScope + +These sources do not by themselves prove BandScope conformance or accuracy. Each cited requirement must map to: + +```text +source requirement or evaluation method +→ BandScope product decision +→ owning issue and implementation +→ exact fixture and rights evidence +→ current-head test/build/release evidence +→ known limitation and supported claim +``` + +Official certification, notarization, code-signing, conformance, or benchmark claims may be made only after the corresponding external process and exact release artifact have been verified. diff --git a/docs/product-readiness/open-pr-queue.json b/docs/product-readiness/open-pr-queue.json new file mode 100644 index 000000000..bc34a2d2d --- /dev/null +++ b/docs/product-readiness/open-pr-queue.json @@ -0,0 +1,1148 @@ +{ + "schema_version": "1.0.0", + "snapshot_date": "2026-08-24", + "timezone": "Asia/Seoul", + "repository": "ContextualWisdomLab/bandscope", + "base_branch": "develop", + "base_sha": "acdbea6344fe1231c39535b575f4de35e4c607c9", + "open_pr_count": 122, + "authority_note": "Generated from a complete live GitHub open-PR inventory. Refresh checks, reviews, threads, ancestry, and writer evidence immediately before action.", + "trains": { + "T0": { + "description": "Dependency, toolchain, workflow and quality base", + "issue": 966 + }, + "T1": { + "description": "Local input, filesystem authority, resource admission and cancellation", + "issue": 781 + }, + "T2": { + "description": "Scientific accuracy, MIR evaluation and numerical parity", + "issue": 770 + }, + "T3": { + "description": "Rehearsal actions and active-player vertical", + "issue": 961 + }, + "T4": { + "description": "Project portability, handoff and interoperability", + "issue": 962 + }, + "T5": { + "description": "Activation, UI system, Storybook and accessibility", + "issue": 965 + }, + "T6": { + "description": "Diagnostics, redaction, security evidence and supportability", + "issue": 963 + }, + "T7": { + "description": "Signed commercial release and updater", + "issue": 960 + }, + "T8": { + "description": "Live additions awaiting explicit merge-train triage", + "issue": 966 + } + }, + "pull_requests": [ + { + "number": 731, + "title": "🎨 Palette: 향상된 접근성을 위해 아이콘 버튼에 툴팁 및 aria-disabled 적용", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/731", + "initial_train": "T5", + "initial_disposition": "triage_required", + "head_sha": "5ea5663f26e33315ac6dda3c4f983f0f12a1cba7", + "head_sha_status": "exact_current_head" + }, + { + "number": 732, + "title": "⚡ Chords: vectorize HMM reference and correct relative-key prior", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/732", + "initial_train": "T2", + "initial_disposition": "triage_required", + "head_sha": "830dd4c982b12e1dcdf896e3e951363f1ec16a15", + "head_sha_status": "exact_current_head" + }, + { + "number": 737, + "title": "feat(integration): add versioned naruon rehearsal handoff", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/737", + "initial_train": "T4", + "initial_disposition": "outbound_naruon_handoff_contract", + "head_sha": "82ae343e9911e30cbfe65f1264367b6ae8576cb6", + "head_sha_status": "exact_current_head" + }, + { + "number": 740, + "title": "feat: import rehearsal handoffs for focused reanalysis", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/740", + "initial_train": "T4", + "initial_disposition": "inbound_handoff_reanalysis_slice", + "head_sha": "4761a882d1b345647fb6ca7bbb7c89c5420a097f", + "head_sha_status": "exact_current_head" + }, + { + "number": 744, + "title": "feat(i18n): localize Workspace controls and harden interpolation", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/744", + "initial_train": "T5", + "initial_disposition": "triage_required", + "head_sha": "0ca00b110d046f8101b01a13fcc3f10d57feebd2", + "head_sha_status": "exact_current_head" + }, + { + "number": 746, + "title": "perf(segmenter): vectorize checkerboard novelty reference", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/746", + "initial_train": "T2", + "initial_disposition": "triage_required", + "head_sha": "9d0def7d5038b62b1eb0a8ce2784e23d6618ad74", + "head_sha_status": "exact_current_head" + }, + { + "number": 750, + "title": "fix(score): validate PDF bridge byte arrays without coercion", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/750", + "initial_train": "T1", + "initial_disposition": "triage_required", + "head_sha": "d982adef81cd54adae37078f71a7976aa122e986", + "head_sha_status": "exact_current_head" + }, + { + "number": 754, + "title": "build(deps): bump time from 0.3.53 to 0.3.55 in /apps/desktop/src-tauri", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/754", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "966d5f1204ec90c303f8903e55f71f67bcba136d", + "head_sha_status": "exact_current_head" + }, + { + "number": 776, + "title": "feat(chords): surface actionable rehearsal guidance", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/776", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "8b38e4885e21619cb502eb6d2be25420e6246fd3", + "head_sha_status": "exact_current_head" + }, + { + "number": 780, + "title": "fix(security): keep every CodeQL Action phase on one revision", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/780", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "738495ca9d425fb34a12e76c0614eda356e68f3a", + "head_sha_status": "exact_current_head" + }, + { + "number": 783, + "title": "fix(security): establish canonical npm, PDF.js, Nanoid, and Undici baseline", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/783", + "initial_train": "T0", + "initial_disposition": "canonical_dependency_security_base", + "head_sha": "1168c8f4257de5de036ea54bf5ee73edb83e775e", + "head_sha_status": "exact_current_head" + }, + { + "number": 811, + "title": "🔒 [보안] CLI 무제한 파일 읽기 취약점 수정", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/811", + "initial_train": "T1", + "initial_disposition": "triage_required", + "head_sha": "54c645c9dcace65dca4e5bb669a665480a26dcb7", + "head_sha_status": "exact_current_head" + }, + { + "number": 824, + "title": "🧪 테스트: 줄기 분리 시간 초과 처리 테스트 추가", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/824", + "initial_train": "T1", + "initial_disposition": "triage_required", + "head_sha": "f4d01558eb3c0c2a0b6c81f9614eeb083b6bb39b", + "head_sha_status": "exact_current_head" + }, + { + "number": 826, + "title": "🧪 [테스트 개선] TemporalAnalyzer 분석 오류 테스트 추가", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/826", + "initial_train": "T6", + "initial_disposition": "triage_required", + "head_sha": "9002ff8b92272c590a57eecdc80c95f71e4bec55", + "head_sha_status": "exact_current_head" + }, + { + "number": 827, + "title": "fix(desktop): remove synthetic browser analysis success", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/827", + "initial_train": "T2", + "initial_disposition": "triage_required", + "head_sha": "a2e5d260e68a0fe36a4d0551af202b73f257dd70", + "head_sha_status": "exact_current_head" + }, + { + "number": 828, + "title": "test(analysis): govern real YouTube known-stem benchmark", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/828", + "initial_train": "T2", + "initial_disposition": "real_audio_accuracy_slice_under_issue_770", + "head_sha": "9331b406e7fbe4d1407f9f7854db1fd9ba12b194", + "head_sha_status": "exact_current_head" + }, + { + "number": 833, + "title": "🎨 Palette: 코드 수정 버튼에 접근성 있는 툴팁 지원 추가", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/833", + "initial_train": "T5", + "initial_disposition": "triage_required", + "head_sha": "3206c259afcc02a1cc625f3e9f785d48b4a799ed", + "head_sha_status": "exact_current_head" + }, + { + "number": 834, + "title": "⚡ Bolt: O(1) 메모리로 순차적 코드 변경 횟수 계산 최적화", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/834", + "initial_train": "T2", + "initial_disposition": "triage_required", + "head_sha": "c686ae450677303d2622abec1869aef6cef98155", + "head_sha_status": "exact_current_head" + }, + { + "number": 845, + "title": "feat(analysis): CSV 큐시트 내보내기 기능 및 수식 주입 보안 로직 추가", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/845", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "d1dc4da583320205ba92dfabb3fd48092a51d9e9", + "head_sha_status": "exact_current_head" + }, + { + "number": 849, + "title": "⚡ Bolt: 차트 내보내기(chart.py)의 중복 제거 로직 O(N^2)에서 O(N)으로 최적화", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/849", + "initial_train": "T5", + "initial_disposition": "triage_required", + "head_sha": "e3268c8bf632d49a524b84cce141f850dd3f252d", + "head_sha_status": "exact_current_head" + }, + { + "number": 850, + "title": "feat: add Part Handoff Map UI component to workspace", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/850", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "adbe3da061722ae0dc98bff368ecff494363cdc9", + "head_sha_status": "exact_current_head" + }, + { + "number": 858, + "title": "fix(security): bind analysis paths to filesystem authority", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/858", + "initial_train": "T1", + "initial_disposition": "triage_required", + "head_sha": "bedc2069494c0687f02873097226de04fa86f484", + "head_sha_status": "exact_current_head" + }, + { + "number": 859, + "title": "⚡ Bolt: GrooveMap의 maxTime 계산 성능 개선", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/859", + "initial_train": "T5", + "initial_disposition": "triage_required", + "head_sha": "38e1027d3a9613da5ea912020fa3f08ef0814e69", + "head_sha_status": "exact_current_head" + }, + { + "number": 861, + "title": "test(quality): enforce Python branch coverage", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/861", + "initial_train": "T0", + "initial_disposition": "canonical_python_branch_coverage_base", + "head_sha": "6afbc9fff585a5a7c2bf0454b0673c3f71f1b0e1", + "head_sha_status": "exact_current_head" + }, + { + "number": 865, + "title": "fix(score): bound native PDF reads before allocation", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/865", + "initial_train": "T1", + "initial_disposition": "triage_required", + "head_sha": "f86e266b2ab2dc5a95e6b4a484e777b29f0feeaf", + "head_sha_status": "exact_current_head" + }, + { + "number": 866, + "title": "fix(audio): establish canonical local-audio resource policy", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/866", + "initial_train": "T1", + "initial_disposition": "canonical_local_audio_resource_policy", + "head_sha": "223dd78126deeb3f12a68dc140f6a83fbe422225", + "head_sha_status": "exact_current_head" + }, + { + "number": 867, + "title": "test(supply-chain): preserve simple dependency-path cycle semantics", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/867", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "f1ac4167b8b7bdb88c92a89c437e0dd5432ba6ae", + "head_sha_status": "exact_current_head" + }, + { + "number": 873, + "title": "feat(roles): wire measured register overlap into section warnings", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/873", + "initial_train": "T2", + "initial_disposition": "triage_required", + "head_sha": "c30be7afa82beeade94c99d2a4bae818aebf121f", + "head_sha_status": "exact_current_head" + }, + { + "number": 874, + "title": "feat(workspace): name the parts to lock in first", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/874", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "05935e02f29366b2bd240d6fb4d1bae784cb992b", + "head_sha_status": "exact_current_head" + }, + { + "number": 881, + "title": "test(ci): lock local OpenCode small_model to NVIDIA NIM", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/881", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "4e07356d1ece6f22c040d6a0cd24c995800f88a9", + "head_sha_status": "exact_current_head" + }, + { + "number": 884, + "title": "feat(workspace): open Stem Lab as honest isolation lanes", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/884", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "e33ca56a3922b78b5569e32e1a949f5a488c7e01", + "head_sha_status": "exact_current_head" + }, + { + "number": 891, + "title": "test(analysis): lock verse/chorus chord recovery on a known take", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/891", + "initial_train": "T2", + "initial_disposition": "triage_required", + "head_sha": "92fe9155a1896e13ba8e06e1762f6848fc2efdfa", + "head_sha_status": "exact_current_head" + }, + { + "number": 892, + "title": "fix(analysis): score C major acceptance from decoded WAV bytes", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/892", + "initial_train": "T2", + "initial_disposition": "triage_required", + "head_sha": "40f138e00ece3d480b5433a5ff8e1d9026cd7ff2", + "head_sha_status": "exact_current_head" + }, + { + "number": 894, + "title": "fix(security): drop persisted credentials before dependency lifecycle code", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/894", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "475adeba62109e1e34981baed22ce89728211108", + "head_sha_status": "exact_current_head" + }, + { + "number": 895, + "title": "fix(workflows): audit orphaned Actions registry identities", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/895", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "9de29814cb307e7e50817e820c2510058d67233f", + "head_sha_status": "exact_current_head" + }, + { + "number": 896, + "title": "build(node): coordinate Node 22.22.2 floor with jsdom 30", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/896", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "c07e51639dbb7b44f57ff0aabc26ad793e691add", + "head_sha_status": "exact_current_head" + }, + { + "number": 897, + "title": "feat(workspace): Storybook tokens for rehearsal components", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/897", + "initial_train": "T5", + "initial_disposition": "storybook_design_inventory_slice", + "head_sha": "d31a6ce740f756902d59edaf4420eea713007c81", + "head_sha_status": "exact_current_head" + }, + { + "number": 898, + "title": "feat(workspace): put the next rehearsal action on empty and error cards", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/898", + "initial_train": "T5", + "initial_disposition": "triage_required", + "head_sha": "48c6084e9e8e74026e5690173c01a5e1479b7026", + "head_sha_status": "exact_current_head" + }, + { + "number": 899, + "title": "feat(workspace): open existing rehearsal surfaces from the sidebar", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/899", + "initial_train": "T5", + "initial_disposition": "triage_required", + "head_sha": "b263ac441df5522a1a943463744f9f732d806adc", + "head_sha_status": "exact_current_head" + }, + { + "number": 900, + "title": "feat(workspace): name tonight's export and priority actions", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/900", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "0c09689fc981d72c2a6d7505c0ab0c41a97d0ebe", + "head_sha_status": "exact_current_head" + }, + { + "number": 901, + "title": "feat(workspace): start tonight's first part from the ready board", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/901", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "940ff24aaeac3261f922a8f33d47dd8abb1abb93", + "head_sha_status": "exact_current_head" + }, + { + "number": 903, + "title": "feat(workspace): loop tonight's first section on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/903", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "1f3e8c75c57232208d2b723aa37137e47ccf46de", + "head_sha_status": "exact_current_head" + }, + { + "number": 905, + "title": "feat(workspace): open tonight's first notes on the groove map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/905", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "a1a38fc4e537f22d7811592e49baeb9d816faeff", + "head_sha_status": "exact_current_head" + }, + { + "number": 907, + "title": "chore(env): add Cloud Agent environment config", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/907", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "b0972c5ad01c506e24566433e8dbf9008c91c4a9", + "head_sha_status": "exact_current_head" + }, + { + "number": 910, + "title": "feat(workspace): set up tonight's part before the first entrance", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/910", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "61aa51f274c04d60289da7919a70d40155f2674d", + "head_sha_status": "exact_current_head" + }, + { + "number": 912, + "title": "feat(workspace): guide tonight's first entrance on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/912", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "b8f588ad6b0d017a4539ed6956fdcf53057292bc", + "head_sha_status": "exact_current_head" + }, + { + "number": 913, + "title": "feat(workspace): guide tonight's first lyric cue on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/913", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "c56a58933c418d9b0d86faca5a663de21f5b1d8c", + "head_sha_status": "exact_current_head" + }, + { + "number": 914, + "title": "feat(workspace): guide tonight's first dropout on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/914", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "a0b5fe92436113fcf57017f4a065e8901a471f9d", + "head_sha_status": "exact_current_head" + }, + { + "number": 916, + "title": "feat(workspace): guide tonight's first pickup on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/916", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "6657a47b706e40d704a6949de383c5d55aa288a5", + "head_sha_status": "exact_current_head" + }, + { + "number": 918, + "title": "build(deps): bump uuid from 1.23.4 to 1.24.1 in /apps/desktop/src-tauri", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/918", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "77a2c35a21eeaf6273ea6bf825d9ea0c1793f33b", + "head_sha_status": "exact_current_head" + }, + { + "number": 919, + "title": "build(deps): update numba requirement from <0.67.0 to <0.68.0 in /services/analysis-engine", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/919", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "b113cec48235bc74660f74d5ed9bcdb5357d74b0", + "head_sha_status": "exact_current_head" + }, + { + "number": 920, + "title": "build(deps): bump react and @types/react", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/920", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "477fa3e363e77cbc0b05b9147f58398f781d6e58", + "head_sha_status": "exact_current_head" + }, + { + "number": 921, + "title": "build(deps): bump @base-ui/react from 1.5.0 to 1.7.0", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/921", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "2840d0d0831d705b357aca15d7ec91bcf6928ca4", + "head_sha_status": "exact_current_head" + }, + { + "number": 922, + "title": "build(deps-dev): bump storybook from 10.4.6 to 10.5.8", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/922", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "5f6f0a809eb05ec6b37417b37581a8927c76e2a8", + "head_sha_status": "exact_current_head" + }, + { + "number": 923, + "title": "build(deps-dev): bump @storybook/react-vite from 10.4.6 to 10.5.8", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/923", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "d3d65db3541850bbec781757341934cdf6a8aca7", + "head_sha_status": "exact_current_head" + }, + { + "number": 924, + "title": "build(deps): bump github/codeql-action/init from 4.37.0 to 4.37.7", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/924", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "bd7cd7d49cf5ea05081b12dca13385ebf67a881e", + "head_sha_status": "exact_current_head" + }, + { + "number": 925, + "title": "build(deps-dev): bump typescript-eslint from 8.63.0 to 8.67.0", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/925", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "525bb99a455361d531ac26a856f70903e9ce14de", + "head_sha_status": "exact_current_head" + }, + { + "number": 926, + "title": "build(deps): bump lucide-react from 1.24.0 to 1.31.0", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/926", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "5b27740c54bbdd81c05008224f6a32ddbaa265fd", + "head_sha_status": "exact_current_head" + }, + { + "number": 927, + "title": "build(deps): bump sonner from 2.0.7 to 2.0.8", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/927", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "235019727df523ea459a6bd09b60208906eb22c8", + "head_sha_status": "exact_current_head" + }, + { + "number": 928, + "title": "build(deps-dev): bump eslint-plugin-jsdoc from 63.0.13 to 64.2.0", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/928", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "f87c0fbdca3b69af444a617412e3d620171b2ee4", + "head_sha_status": "exact_current_head" + }, + { + "number": 929, + "title": "build(deps-dev): bump @testing-library/jest-dom from 6.9.1 to 7.0.1", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/929", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "0ce4cb84cbafcc81cbd824b2a7879ab5549d419a", + "head_sha_status": "exact_current_head" + }, + { + "number": 930, + "title": "build(deps): bump github/codeql-action/autobuild from 4.37.0 to 4.37.7", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/930", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "927c473bbfefc63a08aaf352222372506d836abd", + "head_sha_status": "exact_current_head" + }, + { + "number": 931, + "title": "build(deps): bump astral-sh/setup-uv from 8.3.2 to 10.0.1", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/931", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "8aed8adac44592b6dbfac30bf4cfe419e71eebe0", + "head_sha_status": "exact_current_head" + }, + { + "number": 932, + "title": "build(deps): bump github/codeql-action/analyze from 4.37.0 to 4.37.7", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/932", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "57011511f876883347a5da5f5e2540f356cb0195", + "head_sha_status": "exact_current_head" + }, + { + "number": 933, + "title": "build(deps): bump github/codeql-action/upload-sarif from 4.37.0 to 4.37.7", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/933", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "dff69016587f35141a0f13c1bec84fa19fc18a5a", + "head_sha_status": "exact_current_head" + }, + { + "number": 934, + "title": "feat(workspace): guide tonight's first stop on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/934", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "3d796dfffd64b2331bf4146166449f7725b8b673", + "head_sha_status": "exact_current_head" + }, + { + "number": 936, + "title": "build(deps): bump the uv group across 1 directory with 2 updates", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/936", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "cb048b1f455b3655aa0fcc086b15b0cc496d339a", + "head_sha_status": "exact_current_head" + }, + { + "number": 937, + "title": "feat(workspace): guide tonight's first labeled handoff on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/937", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "c3685178c6f393902748d25c30d087af8c6597b7", + "head_sha_status": "exact_current_head" + }, + { + "number": 939, + "title": "feat(workspace): guide tonight's first chorus on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/939", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "d3c852894bc51425653d6e296c4ea9c69fe533cf", + "head_sha_status": "exact_current_head" + }, + { + "number": 941, + "title": "🛡️ Sentinel: CSV 수식 주입 NUL·전각 연산자 우회 차단", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/941", + "initial_train": "T6", + "initial_disposition": "triage_required", + "head_sha": "f7a2634607c5a7792f05309ec9e284d9d6132ca6", + "head_sha_status": "exact_current_head" + }, + { + "number": 942, + "title": "build(deps): bump react-dom and @types/react-dom", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/942", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "647b883996ed49141e177a5e97138aaca368ef81", + "head_sha_status": "exact_current_head" + }, + { + "number": 943, + "title": "feat(workspace): guide tonight's first intro on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/943", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "4855357e19dd444271f95142667cb226989e1142", + "head_sha_status": "exact_current_head" + }, + { + "number": 944, + "title": "build(rust): pin all product and release lanes to 1.97.1", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/944", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "b0f8cf0de9f02ee1bed7a8ee964daed6d2063562", + "head_sha_status": "exact_current_head" + }, + { + "number": 946, + "title": "feat(workspace): guide tonight's first bridge on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/946", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "4d13986d7d486caae8f87db7d468388624acd29f", + "head_sha_status": "exact_current_head" + }, + { + "number": 947, + "title": "feat(workspace): guide tonight's first verse on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/947", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "315e563ffe6d6ad83c48117e308477f8ca280cbc", + "head_sha_status": "exact_current_head" + }, + { + "number": 948, + "title": "fix(security): retire quick-xml RustSec exceptions", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/948", + "initial_train": "T0", + "initial_disposition": "triage_required", + "head_sha": "d415bc382167c90216fb0cae46821b5597270766", + "head_sha_status": "exact_current_head" + }, + { + "number": 949, + "title": "fix(security): redact key detector dependency failures", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/949", + "initial_train": "T6", + "initial_disposition": "triage_required", + "head_sha": "6ee9ada2228a91d6e1a0de776347890e8ea82132", + "head_sha_status": "exact_current_head" + }, + { + "number": 950, + "title": "fix(security): redact temporal detector failure logs", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/950", + "initial_train": "T6", + "initial_disposition": "triage_required", + "head_sha": "396d42017bd32c9b7215ec8bf8f5bf8669139e85", + "head_sha_status": "exact_current_head" + }, + { + "number": 951, + "title": "fix(security): redact range-analysis failure logs", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/951", + "initial_train": "T6", + "initial_disposition": "triage_required", + "head_sha": "9c64ca3cd3bd2669d012f86b7f3611e7f2463583", + "head_sha_status": "exact_current_head" + }, + { + "number": 955, + "title": "feat(workspace): guide tonight's first pre-chorus on map and player", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/955", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "d1975e182f529cad3acba1c6c6d7d0a74ac4e47a", + "head_sha_status": "exact_current_head" + }, + { + "number": 956, + "title": "fix(security): redact articulation failure logs", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/956", + "initial_train": "T6", + "initial_disposition": "triage_required", + "head_sha": "2707ad39e0198857a9f5eedfddea8a36718257e8", + "head_sha_status": "exact_current_head" + }, + { + "number": 957, + "title": "feat(workspace): name tonight's first playable range on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/957", + "initial_train": "T3", + "initial_disposition": "triage_required", + "head_sha": "4c4c045cfe1eabf585aadb01bf799ed68d654e4f", + "head_sha_status": "exact_current_head" + }, + { + "number": 967, + "title": "feat(operations): add privacy-safe support manifest boundary", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/967", + "initial_train": "T6", + "initial_disposition": "support_manifest_boundary", + "head_sha": "b9881489d297f1f97f985a880df72da886b861a2", + "head_sha_status": "exact_current_head" + }, + { + "number": 968, + "title": "feat(readiness): establish BandScope 1.0 product-readiness baseline", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/968", + "initial_train": "T0", + "initial_disposition": "product_readiness_baseline_program", + "head_sha": "db52897352ae945f80333de8fe60c9159e7f9f2c", + "head_sha_status": "exact_current_head" + }, + { + "number": 969, + "title": "fix(design): restore Figma contract-page inventory and drift check", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/969", + "initial_train": "T5", + "initial_disposition": "figma_contract_inventory_drift_check", + "head_sha": "934b76286632a7d7ade8d99e6ead85a7e751ce4d", + "head_sha_status": "exact_current_head" + }, + { + "number": 970, + "title": "fix(project): stage new saves without clobbering known-good files", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/970", + "initial_train": "T4", + "initial_disposition": "project_atomic_publication", + "head_sha": "2e48e59916c8fb4552e26c26c8a6c65946f6b7fb", + "head_sha_status": "exact_current_head" + }, + { + "number": 971, + "title": "feat(workspace): loop tonight's first section from the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/971", + "initial_train": "T3", + "initial_disposition": "player_first_section_loop", + "head_sha": "7c1b4973f9d7f4ae7320814cfa340d42d29e2706", + "head_sha_status": "exact_current_head" + }, + { + "number": 972, + "title": "feat(workspace): name the next rehearsal action from help", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/972", + "initial_train": "T3", + "initial_disposition": "help_next_action_copy", + "head_sha": "c0095ca8f4015133a7debb6fdac910d573a6ec79", + "head_sha_status": "exact_current_head" + }, + { + "number": 974, + "title": "feat(workspace): start analysis from the first-run card", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/974", + "initial_train": "T5", + "initial_disposition": "first_run_analyze_entry", + "head_sha": "fd33104ca88030b5218e1c56a0cb683e2b50d7db", + "head_sha_status": "exact_current_head" + }, + { + "number": 976, + "title": "feat(workspace): name the next action after analysis fails", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/976", + "initial_train": "T3", + "initial_disposition": "analysis_failure_next_action_copy", + "head_sha": "d07dc57347ce8187bc70b1d1f01b1ac5ec195887", + "head_sha_status": "exact_current_head" + }, + { + "number": 980, + "title": "feat(workspace): name using your own song as the first next action", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/980", + "initial_train": "T5", + "initial_disposition": "first_run_own_song_action", + "head_sha": "9bc2ead0d52f4f3da811305042755512d396a9f3", + "head_sha_status": "exact_current_head" + }, + { + "number": 981, + "title": "feat(workspace): name Choose another song after local intake fails", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/981", + "initial_train": "T3", + "initial_disposition": "local_intake_failure_next_action_copy", + "head_sha": "9059b4ceb5de2b223114611751b9bc26d9f220b2", + "head_sha_status": "exact_current_head" + }, + { + "number": 982, + "title": "feat(workspace): name Paste another YouTube link after import fails", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/982", + "initial_train": "T3", + "initial_disposition": "youtube_import_failure_next_action_copy", + "head_sha": "54f5a59edfea79e42b363bae334d99de13016235", + "head_sha_status": "exact_current_head" + }, + { + "number": 984, + "title": "feat(workspace): name the next action after project load or save fails", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/984", + "initial_train": "T4", + "initial_disposition": "project_save_load_failure_next_action_copy", + "head_sha": "8207813b5ef7637946e6510e09fa734529b1f654", + "head_sha_status": "exact_current_head" + }, + { + "number": 985, + "title": "feat(analysis): enforce one canonical audio resource policy (#781)", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/985", + "initial_train": "T1", + "initial_disposition": "canonical_audio_resource_policy", + "head_sha": "d2cf2047af790cddf02b3957856d246638a754b5", + "head_sha_status": "exact_current_head" + }, + { + "number": 986, + "title": "feat(workspace): guide tonight's first outro on the rehearsal map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/986", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "4222d99c025723329e522f98b7088486a4c3b5ca", + "head_sha_status": "exact_current_head" + }, + { + "number": 987, + "title": "feat(workspace): name tonight's tempo, starting chord, and transpose setup", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/987", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "ec87428f4ef976d3a71570b5ef60c29a315f1c35", + "head_sha_status": "exact_current_head" + }, + { + "number": 989, + "title": "feat(workspace): guide tonight's first tag on the rehearsal map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/989", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "6b83256ff8415d5e8afb0c034f97c6d40674ed79", + "head_sha_status": "exact_current_head" + }, + { + "number": 990, + "title": "feat(workspace): name tonight's first simpler take on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/990", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "3736a1647ec06251214005b82552717fd8dd8b69", + "head_sha_status": "exact_current_head" + }, + { + "number": 991, + "title": "feat(workspace): name tonight's first groove on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/991", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "ac360bd992feb7167fdf0ec92d50413c6df383f9", + "head_sha_status": "exact_current_head" + }, + { + "number": 992, + "title": "feat(workspace): name tonight's first overlap on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/992", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "510347b801bf1d69a32b26b42519dbbb2827e4f7", + "head_sha_status": "exact_current_head" + }, + { + "number": 993, + "title": "feat(workspace): name tonight's first transition cue on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/993", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "151d8ec7336889fecfaead0367b734ef1fbe38d5", + "head_sha_status": "exact_current_head" + }, + { + "number": 994, + "title": "feat(workspace): name tonight's first transition on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/994", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "6e8d5412d8935bf767425c9e6c6adc19e7f19031", + "head_sha_status": "exact_current_head" + }, + { + "number": 995, + "title": "feat(workspace): name tonight's first count on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/995", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "4d353ca99a389189b2d7c1eda04067a071e1ac79", + "head_sha_status": "exact_current_head" + }, + { + "number": 996, + "title": "feat(workspace): name tonight's first assignment on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/996", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "54b75a265ef3b7265461abf0601b6daf52d67b5f", + "head_sha_status": "exact_current_head" + }, + { + "number": 997, + "title": "feat(workspace): name tonight's first open rehearsal comment on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/997", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "fa4aadd429c220bc1e620d475892e2292c92f4b8", + "head_sha_status": "exact_current_head" + }, + { + "number": 998, + "title": "feat(workspace): name tonight's first pending approval on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/998", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "b67afb688866f5436b0c6079ffe4291e918be7ea", + "head_sha_status": "exact_current_head" + }, + { + "number": 999, + "title": "⚡ Bolt: 관측 확률 계산 배열 연산으로 벡터화 (성능 개선)", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/999", + "initial_train": "T2", + "initial_disposition": "observation_probability_vectorization", + "head_sha": "c11f5ed592bd982d1148050f7cec6a1573ce5160", + "head_sha_status": "exact_current_head" + }, + { + "number": 1000, + "title": "feat(workspace): name tonight's first blocked assignment on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1000", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "6c6b3e75616e36f7b2997b92ea13bdca7628b523", + "head_sha_status": "exact_current_head" + }, + { + "number": 1001, + "title": "feat(workspace): name tonight's first ear check on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1001", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "38781c86dbcfdee00da1072062056deeada54afe", + "head_sha_status": "exact_current_head" + }, + { + "number": 1002, + "title": "feat(workspace): name tonight's first confirmed chord on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1002", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "1a1f231052d1cecadf1178b14cc9ff1e43a21975", + "head_sha_status": "exact_current_head" + }, + { + "number": 1003, + "title": "feat(workspace): name tonight's first harmonic explanation on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1003", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "8f88db8c23134afb4e2e726b10b1cada7bb969d0", + "head_sha_status": "exact_current_head" + }, + { + "number": 1004, + "title": "feat(workspace): name tonight's first setup note on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1004", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "9411885d68ed7a08d880043dca4f52bc6166abce", + "head_sha_status": "exact_current_head" + }, + { + "number": 1005, + "title": "feat(workspace): name tonight's first harmonic function on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1005", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "00cf87dca1bd334cb5c891b3bc3bd19510d10611", + "head_sha_status": "exact_current_head" + }, + { + "number": 1006, + "title": "feat(workspace): name tonight's first transposition plan on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1006", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "f1e86e9c66c09252fbedd6626f015cf370464c56", + "head_sha_status": "exact_current_head" + }, + { + "number": 1007, + "title": "feat(workspace): name tonight's first part handoff on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1007", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "e06ca27fad4792f8b191fd314a630e5932580007", + "head_sha_status": "exact_current_head" + }, + { + "number": 1008, + "title": "feat(workspace): name tonight's first capo plan on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1008", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "e1673b5968411cde72ce8945281c5db5779e145f", + "head_sha_status": "exact_current_head" + }, + { + "number": 1009, + "title": "feat(activation): license a demo song and name first-run next actions", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1009", + "initial_train": "T5", + "initial_disposition": "licensed_demo_first_run_activation", + "head_sha": "86004787d77cdcfbcc3d411124e9b01ed2898a71", + "head_sha_status": "exact_current_head" + }, + { + "number": 1010, + "title": "feat(workspace): name tonight's first tuning plan on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1010", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "5b07db76282e89bf5c7aefb2cd680343c2d63f05", + "head_sha_status": "exact_current_head" + }, + { + "number": 1011, + "title": "feat(workspace): name tonight's first dynamics plan on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1011", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "1d077d091025ab90b2831069a689b4bddb782725", + "head_sha_status": "exact_current_head" + }, + { + "number": 1012, + "title": "feat(workspace): name tonight's first articulation plan on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1012", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "039b68cf556d997c2a9145cc0250b93aa254f0c7", + "head_sha_status": "exact_current_head" + }, + { + "number": 1013, + "title": "feat(workspace): name tonight's first voicing plan on the map", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/1013", + "initial_train": "T3", + "initial_disposition": "workspace_rehearsal_map_slice", + "head_sha": "812d60a93cf4ee5ac9cdceb4f96ce4b9f09cf7b5", + "head_sha_status": "exact_current_head" + } + ] +} diff --git a/scripts/checks/collect_open_pr_readiness.py b/scripts/checks/collect_open_pr_readiness.py new file mode 100755 index 000000000..25183a89b --- /dev/null +++ b/scripts/checks/collect_open_pr_readiness.py @@ -0,0 +1,686 @@ +#!/usr/bin/env python3 +"""Collect exact-head PR readiness receipts from bounded live GitHub authority.""" + +from __future__ import annotations + +import argparse +import http.client +import json +import os +import tempfile +import urllib.parse +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from verify_open_pr_queue import ManifestError, load_manifest, validate_manifest + +REPOSITORY = "ContextualWisdomLab/bandscope" +REPOSITORY_OWNER = "ContextualWisdomLab" +REPOSITORY_NAME = "bandscope" +GITHUB_API_HOST = "api.github.com" +REPOSITORY_API_PREFIX = f"/repos/{REPOSITORY}/" +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_MANIFEST_PATH = Path("docs/product-readiness/open-pr-queue.json") +DEFAULT_OUTPUT_PATH = Path("docs/product-readiness/open-pr-readiness.json") +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 +MAX_OPEN_PRS = 1000 +GRAPHQL_BATCH_SIZE = 20 +MAX_STATUS_CONTEXTS = 100 +MAX_REVIEWS = 100 +MAX_REVIEW_THREADS = 100 +PASSING_CHECK_CONCLUSIONS = frozenset({"SUCCESS"}) +PASSING_STATUS_STATES = frozenset({"SUCCESS"}) + + +class ReadinessError(ValueError): + """Raised when readiness evidence is missing, stale, incomplete, or malformed.""" + + +def _fail(message: str) -> None: + raise ReadinessError(message) + + +def _record(value: object, field: str) -> dict[str, Any]: + if not isinstance(value, dict): + _fail(f"{field} must be an object") + return value + + +def _list(value: object, field: str) -> list[Any]: + if not isinstance(value, list): + _fail(f"{field} must be an array") + return value + + +def _text(value: object, field: str) -> str: + if not isinstance(value, str) or not value.strip() or value != value.strip(): + _fail(f"{field} must be a non-empty trim-stable string") + return value + + +def _positive_int(value: object, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + _fail(f"{field} must be a positive integer") + return value + + +def _nullable_positive_int(value: object, field: str) -> int | None: + if value is None: + return None + return _positive_int(value, field) + + +def _request_json( + method: str, + target: str, + token: str, + *, + body: object | None = None, +) -> object: + if not token: + _fail("GITHUB_TOKEN is required for complete live readiness evidence") + if method == "GET": + if not target.startswith(REPOSITORY_API_PREFIX): + _fail("GitHub REST target escaped the canonical repository") + elif method == "POST": + if target != "/graphql": + _fail("GitHub POST target must be the canonical GraphQL endpoint") + else: + _fail("unsupported GitHub request method") + + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "bandscope-open-pr-readiness", + "Authorization": f"Bearer {token}", + } + encoded_body: bytes | None = None + if body is not None: + encoded_body = json.dumps(body, separators=(",", ":")).encode("utf-8") + headers["Content-Type"] = "application/json" + + connection = http.client.HTTPSConnection(GITHUB_API_HOST, timeout=20) + try: + connection.request(method, target, body=encoded_body, headers=headers) + response = connection.getresponse() + if response.status != 200: + _fail(f"GitHub API request failed with HTTP status {response.status}") + encoded = response.read(MAX_RESPONSE_BYTES + 1) + if len(encoded) > MAX_RESPONSE_BYTES: + _fail("GitHub API response exceeded the bounded response size") + payload = json.loads(encoded.decode("utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError, http.client.HTTPException) as exc: + raise ReadinessError(f"GitHub API request failed: {type(exc).__name__}") from exc + finally: + connection.close() + + if isinstance(payload, dict) and payload.get("errors"): + errors = _list(payload["errors"], "GraphQL errors") + first = _record(errors[0], "GraphQL errors[0]") if errors else {} + message = first.get("message", "unknown GraphQL error") + _fail(f"GitHub GraphQL request failed: {message}") + return payload + + +def fetch_base_policy(branch_ref: str, expected_sha: str, token: str) -> dict[str, object]: + """Resolve the target branch again and capture its effective required-check policy.""" + normalized_ref = _text(branch_ref, "base_ref") + encoded_ref = urllib.parse.quote(normalized_ref, safe="") + payload = _record( + _request_json("GET", f"{REPOSITORY_API_PREFIX}branches/{encoded_ref}", token), + f"branch {normalized_ref}", + ) + commit = _record(payload.get("commit"), f"branch {normalized_ref}.commit") + live_sha = _text(commit.get("sha"), f"branch {normalized_ref}.commit.sha").lower() + if live_sha != expected_sha.lower(): + _fail(f"base branch moved during readiness capture: {normalized_ref}") + + protected = payload.get("protected") + if not isinstance(protected, bool): + _fail(f"branch {normalized_ref}.protected must be boolean") + + required: list[dict[str, object]] = [] + if protected: + protection = _record(payload.get("protection"), f"branch {normalized_ref}.protection") + status_checks = protection.get("required_status_checks") + if status_checks is not None: + status_record = _record( + status_checks, f"branch {normalized_ref}.protection.required_status_checks" + ) + checks = status_record.get("checks") + if checks is not None: + for index, raw_check in enumerate( + _list(checks, f"branch {normalized_ref}.required checks") + ): + check = _record(raw_check, f"branch {normalized_ref}.required checks[{index}]") + context = _text( + check.get("context"), + f"branch {normalized_ref}.required checks[{index}].context", + ) + app_id = _nullable_positive_int( + check.get("app_id"), + f"branch {normalized_ref}.required checks[{index}].app_id", + ) + required.append({"context": context, "app_id": app_id}) + else: + for index, raw_context in enumerate( + _list(status_record.get("contexts", []), f"branch {normalized_ref}.contexts") + ): + required.append( + { + "context": _text( + raw_context, f"branch {normalized_ref}.contexts[{index}]" + ), + "app_id": None, + } + ) + required.sort(key=lambda item: (str(item["context"]), int(item["app_id"] or 0))) + return { + "base_ref": normalized_ref, + "base_sha": live_sha, + "protected": protected, + "required_checks": required, + } + + +def _graphql_batch_query(numbers: list[int]) -> str: + if not numbers or len(numbers) > GRAPHQL_BATCH_SIZE: + _fail("GraphQL batch size is outside the reviewed bound") + aliases: list[str] = [] + for number in numbers: + _positive_int(number, "pull request number") + aliases.append( + f""" + p{number}: pullRequest(number: {number}) {{ + number + headRefOid + author {{ login }} + reviewThreads(first: {MAX_REVIEW_THREADS}) {{ + pageInfo {{ hasNextPage }} + nodes {{ isResolved isOutdated }} + }} + reviews(last: {MAX_REVIEWS}) {{ + pageInfo {{ hasPreviousPage }} + nodes {{ + state + submittedAt + author {{ login }} + commit {{ oid }} + }} + }} + commits(last: 1) {{ + nodes {{ + commit {{ + oid + statusCheckRollup {{ + contexts(first: {MAX_STATUS_CONTEXTS}) {{ + pageInfo {{ hasNextPage }} + nodes {{ + __typename + ... on CheckRun {{ + name + status + conclusion + app {{ databaseId }} + }} + ... on StatusContext {{ + context + state + }} + }} + }} + }} + }} + }} + }} + }}""" + ) + joined = "\n".join(aliases) + return f"""query BandScopeReadiness {{ + repository(owner: \"{REPOSITORY_OWNER}\", name: \"{REPOSITORY_NAME}\") {{ +{joined} + }} +}}""" + + +def fetch_pr_batch(numbers: list[int], token: str) -> dict[int, dict[str, Any]]: + """Fetch checks, reviews and thread state for a bounded PR batch in one GraphQL request.""" + payload = _record( + _request_json( + "POST", + "/graphql", + token, + body={"query": _graphql_batch_query(numbers)}, + ), + "GraphQL response", + ) + data = _record(payload.get("data"), "GraphQL response.data") + repository = _record(data.get("repository"), "GraphQL response.data.repository") + result: dict[int, dict[str, Any]] = {} + for number in numbers: + node = repository.get(f"p{number}") + if node is None: + _fail(f"pull request {number} disappeared during readiness capture") + result[number] = _record(node, f"pull request {number}") + return result + + +def _normalize_check_contexts(pr_node: dict[str, Any], expected_head: str) -> list[dict[str, object]]: + commits = _record(pr_node.get("commits"), "pull request commits") + commit_nodes = _list(commits.get("nodes"), "pull request commits.nodes") + if len(commit_nodes) != 1: + _fail("pull request must expose exactly one latest commit node") + pr_commit = _record(commit_nodes[0], "pull request latest commit") + commit = _record(pr_commit.get("commit"), "pull request latest commit.commit") + observed_head = _text(commit.get("oid"), "pull request latest commit oid").lower() + if observed_head != expected_head.lower(): + _fail("status rollup commit does not match the exact current head") + + rollup = commit.get("statusCheckRollup") + if rollup is None: + return [] + rollup_record = _record(rollup, "statusCheckRollup") + contexts = _record(rollup_record.get("contexts"), "statusCheckRollup.contexts") + page_info = _record(contexts.get("pageInfo"), "statusCheckRollup.contexts.pageInfo") + if page_info.get("hasNextPage") is not False: + _fail("status-check context pagination bound would truncate readiness evidence") + + normalized: list[dict[str, object]] = [] + for index, raw_context in enumerate( + _list(contexts.get("nodes"), "statusCheckRollup.contexts.nodes") + ): + context = _record(raw_context, f"status context[{index}]") + typename = context.get("__typename") + if typename == "CheckRun": + app = context.get("app") + app_id: int | None = None + if app is not None: + app_id = _nullable_positive_int( + _record(app, f"status context[{index}].app").get("databaseId"), + f"status context[{index}].app.databaseId", + ) + normalized.append( + { + "context": _text(context.get("name"), f"status context[{index}].name"), + "app_id": app_id, + "passing": ( + context.get("status") == "COMPLETED" + and context.get("conclusion") in PASSING_CHECK_CONCLUSIONS + ), + } + ) + elif typename == "StatusContext": + normalized.append( + { + "context": _text(context.get("context"), f"status context[{index}].context"), + "app_id": None, + "passing": context.get("state") in PASSING_STATUS_STATES, + } + ) + else: + _fail(f"unsupported status context type: {typename}") + return normalized + + +def evaluate_required_checks( + policy: dict[str, object], + observed_contexts: list[dict[str, object]], +) -> tuple[str, list[str]]: + """Return a fail-closed required-check state and exact non-passing identities.""" + if policy.get("protected") is not True: + return "unprotected_base", [] + required = _list(policy.get("required_checks"), "required_checks") + if not required: + return "no_required_checks", [] + + failures: list[str] = [] + for index, raw_required in enumerate(required): + required_check = _record(raw_required, f"required_checks[{index}]") + context = _text(required_check.get("context"), f"required_checks[{index}].context") + app_id = _nullable_positive_int( + required_check.get("app_id"), f"required_checks[{index}].app_id" + ) + matching = [ + item + for item in observed_contexts + if item.get("context") == context + and (app_id is None or item.get("app_id") == app_id) + ] + if not matching or not any(item.get("passing") is True for item in matching): + failures.append(f"{context}@{app_id}" if app_id is not None else context) + return ("passing" if not failures else "non_passing", sorted(failures)) + + +def derive_review_state( + pr_node: dict[str, Any], + *, + expected_head: str, + pr_author: str, +) -> tuple[str, str | None, int]: + """Evaluate only independent review submissions explicitly tied to the exact head.""" + reviews = _record(pr_node.get("reviews"), "pull request reviews") + page_info = _record(reviews.get("pageInfo"), "pull request reviews.pageInfo") + if page_info.get("hasPreviousPage") is not False: + _fail("review pagination bound would truncate readiness evidence") + + approvals = 0 + changes_requested = 0 + for index, raw_review in enumerate(_list(reviews.get("nodes"), "pull request reviews.nodes")): + review = _record(raw_review, f"review[{index}]") + author = review.get("author") + if author is None: + continue + login = _text( + _record(author, f"review[{index}].author").get("login"), + f"review[{index}].author.login", + ) + if login.casefold() == pr_author.casefold(): + continue + commit = review.get("commit") + if commit is None: + continue + reviewed_sha = _text( + _record(commit, f"review[{index}].commit").get("oid"), + f"review[{index}].commit.oid", + ).lower() + if reviewed_sha != expected_head.lower(): + continue + state = review.get("state") + if state == "APPROVED": + approvals += 1 + elif state == "CHANGES_REQUESTED": + changes_requested += 1 + + if changes_requested: + return "changes_requested", expected_head.lower(), approvals + if approvals: + return "approved", expected_head.lower(), approvals + return "review_required", None, 0 + + +def unresolved_actionable_thread_count(pr_node: dict[str, Any]) -> int: + """Count unresolved, non-outdated review threads without treating old code as current action.""" + threads = _record(pr_node.get("reviewThreads"), "reviewThreads") + page_info = _record(threads.get("pageInfo"), "reviewThreads.pageInfo") + if page_info.get("hasNextPage") is not False: + _fail("review-thread pagination bound would truncate readiness evidence") + count = 0 + for index, raw_thread in enumerate(_list(threads.get("nodes"), "reviewThreads.nodes")): + thread = _record(raw_thread, f"reviewThread[{index}]") + resolved = thread.get("isResolved") + outdated = thread.get("isOutdated") + if not isinstance(resolved, bool) or not isinstance(outdated, bool): + _fail(f"reviewThread[{index}] resolution fields must be boolean") + if not resolved and not outdated: + count += 1 + return count + + +def _decision_metadata(queue_entry: dict[str, Any], trains: dict[str, Any]) -> dict[str, object]: + """Bind a reviewed merge-train decision to the queue entry without synthesizing authority.""" + if "disposition" not in queue_entry: + train_name = _text(queue_entry.get("initial_train"), "initial_train") + train = _record(trains.get(train_name), f"trains.{train_name}") + owner_issue = _positive_int(train.get("issue"), f"trains.{train_name}.issue") + rationale = _text(queue_entry.get("initial_disposition"), "initial_disposition") + return { + "disposition": "refresh_required", + "decision_owner": f"issue:#{owner_issue}", + "decision_rationale": rationale, + "decision_timestamp": None, + "decision_metadata_state": "timestamp_required", + } + + disposition = _text(queue_entry.get("disposition"), "disposition") + if disposition == "refresh_required": + return { + "disposition": disposition, + "decision_owner": None, + "decision_rationale": None, + "decision_timestamp": None, + "decision_metadata_state": "refresh_required", + } + + decision_timestamp = _text(queue_entry.get("decision_timestamp"), "decision_timestamp") + try: + datetime.strptime(decision_timestamp, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as exc: + raise ReadinessError("decision_timestamp must use YYYY-MM-DDTHH:MM:SSZ") from exc + return { + "disposition": disposition, + "decision_owner": _text(queue_entry.get("decision_owner"), "decision_owner"), + "decision_rationale": _text(queue_entry.get("decision_rationale"), "decision_rationale"), + "decision_timestamp": decision_timestamp, + "decision_metadata_state": "complete", + } + + +def build_receipt( + queue_entry: dict[str, Any], + *, + policy: dict[str, object], + pr_node: dict[str, Any], + trains: dict[str, Any], + captured_at: str, +) -> dict[str, object]: + number = _positive_int(queue_entry.get("number"), "queue entry number") + expected_head = _text(queue_entry.get("head_sha"), f"PR {number} head_sha").lower() + if pr_node.get("number") != number: + _fail(f"GraphQL PR identity mismatch for {number}") + graphql_head = _text(pr_node.get("headRefOid"), f"PR {number} headRefOid").lower() + if graphql_head != expected_head: + _fail(f"pull request {number} moved during readiness capture") + + author = _record(pr_node.get("author"), f"PR {number} author") + pr_author = _text(author.get("login"), f"PR {number} author.login") + observed_contexts = _normalize_check_contexts(pr_node, expected_head) + required_check_state, non_passing_checks = evaluate_required_checks(policy, observed_contexts) + review_decision, reviewed_sha, approval_count = derive_review_state( + pr_node, expected_head=expected_head, pr_author=pr_author + ) + thread_count = unresolved_actionable_thread_count(pr_node) + decision = _decision_metadata(queue_entry, trains) + + ready = ( + queue_entry.get("draft") is False + and required_check_state == "passing" + and review_decision == "approved" + and thread_count == 0 + and decision["decision_metadata_state"] == "complete" + and decision["disposition"] == "canonical_active" + ) + return { + "number": number, + "head_sha": expected_head, + "base_ref": _text(queue_entry.get("base_ref"), f"PR {number} base_ref"), + "base_sha": _text(queue_entry.get("base_sha"), f"PR {number} base_sha").lower(), + "draft": bool(queue_entry.get("draft")), + "required_check_state": required_check_state, + "non_passing_required_checks": non_passing_checks, + "review_decision": review_decision, + "reviewed_sha": reviewed_sha, + "current_head_approval_count": approval_count, + "unresolved_actionable_thread_count": thread_count, + **decision, + "captured_at": captured_at, + "receipt_state": "passing" if ready else "non_passing", + } + + +def validate_readiness_document(document: object) -> None: + root = _record(document, "readiness") + if root.get("schema_version") != "1.0.0": + _fail("readiness.schema_version must be 1.0.0") + if root.get("repository") != REPOSITORY: + _fail(f"readiness.repository must be {REPOSITORY}") + captured_at = _text(root.get("captured_at"), "readiness.captured_at") + try: + datetime.strptime(captured_at, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as exc: + raise ReadinessError("readiness.captured_at must use YYYY-MM-DDTHH:MM:SSZ") from exc + + receipts = _list(root.get("receipts"), "readiness.receipts") + open_pr_count = root.get("open_pr_count") + if isinstance(open_pr_count, bool) or not isinstance(open_pr_count, int): + _fail("readiness.open_pr_count must be an integer") + if open_pr_count != len(receipts): + _fail("readiness.open_pr_count must equal receipts length") + + seen: set[int] = set() + for index, raw_receipt in enumerate(receipts): + receipt = _record(raw_receipt, f"readiness.receipts[{index}]") + number = _positive_int(receipt.get("number"), f"readiness.receipts[{index}].number") + if number in seen: + _fail(f"duplicate readiness receipt: {number}") + seen.add(number) + _text(receipt.get("head_sha"), f"readiness.receipts[{index}].head_sha") + if receipt.get("required_check_state") not in { + "passing", "non_passing", "unprotected_base", "no_required_checks" + }: + _fail(f"readiness.receipts[{index}].required_check_state is unsupported") + if receipt.get("review_decision") not in { + "approved", "changes_requested", "review_required" + }: + _fail(f"readiness.receipts[{index}].review_decision is unsupported") + thread_count = receipt.get("unresolved_actionable_thread_count") + if isinstance(thread_count, bool) or not isinstance(thread_count, int) or thread_count < 0: + _fail(f"readiness.receipts[{index}].unresolved_actionable_thread_count must be non-negative") + if receipt.get("decision_metadata_state") not in { + "complete", "timestamp_required", "refresh_required" + }: + _fail(f"readiness.receipts[{index}].decision_metadata_state is unsupported") + if receipt.get("receipt_state") == "passing": + if ( + receipt.get("required_check_state") != "passing" + or receipt.get("review_decision") != "approved" + or thread_count != 0 + or receipt.get("decision_metadata_state") != "complete" + or receipt.get("disposition") != "canonical_active" + or receipt.get("draft") is not False + ): + _fail(f"readiness receipt {number} is a false-green") + + +def build_document(manifest: object, token: str) -> dict[str, object]: + try: + validate_manifest(manifest) + except ManifestError as exc: + raise ReadinessError(f"queue manifest is invalid: {exc}") from exc + queue = _record(manifest, "queue manifest") + entries = _list(queue.get("pull_requests"), "queue pull_requests") + if len(entries) > MAX_OPEN_PRS: + _fail("open PR count exceeds the reviewed readiness bound") + trains = _record(queue.get("trains"), "queue trains") + + policies: dict[str, dict[str, object]] = {} + for raw_entry in entries: + entry = _record(raw_entry, "queue entry") + base_ref = _text(entry.get("base_ref"), "queue entry base_ref") + base_sha = _text(entry.get("base_sha"), "queue entry base_sha").lower() + prior = policies.get(base_ref) + if prior is None: + policies[base_ref] = fetch_base_policy(base_ref, base_sha, token) + elif prior.get("base_sha") != base_sha: + _fail(f"queue contains inconsistent live target tips for {base_ref}") + + by_number: dict[int, dict[str, Any]] = {} + numbers = [ + _positive_int(_record(item, "queue entry").get("number"), "queue entry number") + for item in entries + ] + for start in range(0, len(numbers), GRAPHQL_BATCH_SIZE): + batch = numbers[start : start + GRAPHQL_BATCH_SIZE] + by_number.update(fetch_pr_batch(batch, token)) + + captured_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + receipts: list[dict[str, object]] = [] + for raw_entry in entries: + entry = _record(raw_entry, "queue entry") + number = _positive_int(entry.get("number"), "queue entry number") + base_ref = _text(entry.get("base_ref"), f"PR {number} base_ref") + receipts.append( + build_receipt( + entry, + policy=policies[base_ref], + pr_node=by_number[number], + trains=trains, + captured_at=captured_at, + ) + ) + + document: dict[str, object] = { + "schema_version": "1.0.0", + "repository": REPOSITORY, + "captured_at": captured_at, + "open_pr_count": len(receipts), + "receipts": receipts, + } + validate_readiness_document(document) + return document + + +def write_atomic( + document: dict[str, object], + path: Path, + *, + repository_root: Path = REPO_ROOT, +) -> None: + """Publish the receipt inside a repository-owned, non-symlink parent chain.""" + root = repository_root.absolute() + candidate = path if path.is_absolute() else root / path + try: + relative = candidate.relative_to(root) + except ValueError: + _fail("readiness output path escaped the repository root") + if len(relative.parts) < 2: + _fail("readiness output path must have a repository-owned parent") + + current = root + for component in relative.parts[:-1]: + current = current / component + if current.is_symlink(): + _fail("readiness output parent must not contain symbolic links") + if not current.is_dir(): + _fail("readiness output parent must be an existing directory") + if candidate.is_symlink(): + _fail("readiness output path must not be a symbolic link") + + encoded = json.dumps(document, indent=2, ensure_ascii=False) + "\n" + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{candidate.name}.", suffix=".tmp", dir=current + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, candidate) + finally: + if temporary.exists(): + temporary.unlink() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST_PATH) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT_PATH) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + manifest = load_manifest(args.manifest) + document = build_document(manifest, os.environ.get("GITHUB_TOKEN", "")) + write_atomic(document, args.output) + except (ManifestError, ReadinessError) as exc: + print(f"open PR readiness collection failed: {exc}", file=os.sys.stderr) + return 1 + print(f"open PR readiness collected: {document['open_pr_count']} exact-head receipts") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/refresh_open_pr_queue.py b/scripts/checks/refresh_open_pr_queue.py new file mode 100644 index 000000000..f72cc3512 --- /dev/null +++ b/scripts/checks/refresh_open_pr_queue.py @@ -0,0 +1,589 @@ +#!/usr/bin/env python3 +"""Refresh the BandScope open-PR readiness manifest from bounded live GitHub evidence.""" + +from __future__ import annotations + +import http.client +import json +import os +import re +import sys +import urllib.parse +from collections.abc import Callable +from copy import deepcopy +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from verify_open_pr_queue import ManifestError, load_manifest, validate_manifest + +REPO_ROOT = Path(__file__).resolve().parents[2] +MANIFEST_PATH = REPO_ROOT / "docs" / "product-readiness" / "open-pr-queue.json" +REPOSITORY = "ContextualWisdomLab/bandscope" +BASE_BRANCH = "develop" +GITHUB_API_HOST = "api.github.com" +REPOSITORY_API_PREFIX = f"/repos/{REPOSITORY}/" +BRANCH_REF_PREFIX = "refs/heads/" +PAGE_SIZE = 100 +MAX_PAGES = 10 +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 +UNTRIAGED_TRAIN = "T8" +UNTRIAGED_DESCRIPTION = "Live additions awaiting explicit merge-train triage" +UNTRIAGED_ISSUE = 966 +SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) +PageFetcher = Callable[[int, int], tuple[list[dict[str, Any]], bool]] + + +class RefreshError(ValueError): + """Raised when live queue evidence is incomplete, malformed, or ambiguous.""" + + +def _fail(message: str) -> None: + """Raise the stable live-refresh exception.""" + raise RefreshError(message) + + +def _require_record(value: object, field: str) -> dict[str, Any]: + """Return a mapping or fail with a field-specific diagnostic.""" + if not isinstance(value, dict): + _fail(f"{field} must be an object") + return value + + +def _require_list(value: object, field: str) -> list[Any]: + """Return a list or fail with a field-specific diagnostic.""" + if not isinstance(value, list): + _fail(f"{field} must be an array") + return value + + +def _require_text(value: object, field: str) -> str: + """Return trim-stable non-empty text without coercion.""" + if not isinstance(value, str) or not value.strip() or value != value.strip(): + _fail(f"{field} must be a non-empty trim-stable string") + return value + + +def _require_sha(value: object, field: str) -> str: + """Return a normalized immutable Git commit SHA.""" + text = _require_text(value, field) + if SHA_PATTERN.fullmatch(text) is None: + _fail(f"{field} must be 40 hexadecimal characters") + return text.lower() + + +def _require_positive_int(value: object, field: str) -> int: + """Return a positive integer without accepting booleans.""" + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + _fail(f"{field} must be a positive integer") + return value + + +def _require_github_timestamp(value: object, field: str) -> str: + """Return a strict UTC GitHub timestamp for queue-freshness evidence.""" + text = _require_text(value, field) + try: + datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as exc: + raise RefreshError(f"{field} must use YYYY-MM-DDTHH:MM:SSZ") from exc + return text + + +def _normalize_base_tips(base_tips: object | None, protected_base_sha: str) -> dict[str, str]: + """Normalize independently resolved target-branch tips used by live PR records.""" + if base_tips is None: + return {BASE_BRANCH: protected_base_sha} + base_tip_record = _require_record(base_tips, "base_tips") + normalized: dict[str, str] = {} + for raw_ref, raw_sha in base_tip_record.items(): + branch_ref = _require_text(raw_ref, "base_tips branch ref") + normalized[branch_ref] = _require_sha(raw_sha, f"base_tips.{branch_ref}") + if normalized.get(BASE_BRANCH) != protected_base_sha: + _fail(f"base_tips.{BASE_BRANCH} must match the protected base_sha") + return normalized + + +def collect_paginated_pulls( + fetch_page: PageFetcher, + *, + page_size: int = PAGE_SIZE, + max_pages: int = MAX_PAGES, +) -> dict[str, object]: + """Collect every announced page or fail instead of returning partial evidence.""" + _require_positive_int(page_size, "page_size") + _require_positive_int(max_pages, "max_pages") + pull_requests: list[dict[str, Any]] = [] + for page in range(1, max_pages + 1): + items, has_next = fetch_page(page, page_size) + if not isinstance(items, list) or any(not isinstance(item, dict) for item in items): + _fail(f"pull request page {page} must contain objects") + if not isinstance(has_next, bool): + _fail(f"pull request page {page} next-page marker must be boolean") + if has_next and not items: + _fail(f"pull request page {page} is empty but announces another page") + pull_requests.extend(items) + if not has_next: + return {"incomplete_results": False, "pull_requests": pull_requests} + _fail("live pull-request pagination bound would truncate the queue") + + +def collect_paginated_branch_refs( + fetch_page: PageFetcher, + *, + page_size: int = PAGE_SIZE, + max_pages: int = MAX_PAGES, +) -> list[dict[str, Any]]: + """Collect every announced branch-ref page or fail rather than truncate base authority.""" + _require_positive_int(page_size, "page_size") + _require_positive_int(max_pages, "max_pages") + branch_refs: list[dict[str, Any]] = [] + for page in range(1, max_pages + 1): + items, has_next = fetch_page(page, page_size) + if not isinstance(items, list) or any(not isinstance(item, dict) for item in items): + _fail(f"branch-ref page {page} must contain objects") + if not isinstance(has_next, bool): + _fail(f"branch-ref page {page} next-page marker must be boolean") + if has_next and not items: + _fail(f"branch-ref page {page} is empty but announces another page") + branch_refs.extend(items) + if not has_next: + return branch_refs + _fail("live branch-ref pagination bound would truncate the inventory") + + +def _decision_metadata_for_live_head( + prior: dict[str, Any] | None, + current_head_sha: str, + current_base_ref: str, + current_base_sha: str, +) -> dict[str, object]: + """Preserve review only while exact PR head and target-base identity are unchanged.""" + if ( + prior is None + or prior.get("head_sha") != current_head_sha + or prior.get("base_ref") != current_base_ref + or prior.get("base_sha") != current_base_sha + or "disposition" not in prior + ): + return { + "disposition": "refresh_required", + "decision_timestamp": None, + "decision_rationale": None, + "decision_owner": None, + } + return { + "disposition": prior.get("disposition"), + "decision_timestamp": prior.get("decision_timestamp"), + "decision_rationale": prior.get("decision_rationale"), + "decision_owner": prior.get("decision_owner"), + } + + +def _invalidate_decisions_after_related_identity_movement( + refreshed_items: list[dict[str, object]], + existing: dict[int, dict[str, Any]], +) -> None: + """Invalidate reviewed routing when any referenced PR head or target-base identity moved.""" + current_by_number = {int(item["number"]): item for item in refreshed_items} + for entry in refreshed_items: + number = int(entry["number"]) + prior = existing.get(number) + if prior is None or entry.get("disposition") == "refresh_required": + continue + + related_numbers = set(prior.get("predecessor_prs", [])) + related_numbers.update(prior.get("overlap_prs", [])) + successor = prior.get("successor_pr") + if successor is not None: + related_numbers.add(successor) + + for related_number in related_numbers: + reviewed_related = existing.get(related_number) + current_related = current_by_number.get(related_number) + if reviewed_related is None or current_related is None: + entry.update( + { + "disposition": "refresh_required", + "decision_timestamp": None, + "decision_rationale": None, + "decision_owner": None, + } + ) + break + if any( + reviewed_related.get(field) != current_related.get(field) + for field in ("head_sha", "base_ref", "base_sha") + ): + entry.update( + { + "disposition": "refresh_required", + "decision_timestamp": None, + "decision_rationale": None, + "decision_owner": None, + } + ) + break + + +def _live_pr_entry( + raw_pr: object, + *, + index: int, + base_tips: dict[str, str], + existing: dict[int, dict[str, Any]], +) -> dict[str, object]: + """Convert one trusted pulls-API record into the reviewed manifest schema.""" + pr = _require_record(raw_pr, f"pull_requests[{index}]") + number = _require_positive_int(pr.get("number"), f"pull_requests[{index}].number") + if pr.get("state") != "open": + _fail(f"pull_requests[{index}].state must be open") + title = _require_text(pr.get("title"), f"pull_requests[{index}].title") + expected_url = f"https://github.com/{REPOSITORY}/pull/{number}" + if pr.get("html_url") != expected_url: + _fail(f"pull_requests[{index}].html_url must be {expected_url}") + draft = pr.get("draft") + if not isinstance(draft, bool): + _fail(f"pull_requests[{index}].draft must be boolean") + updated_at = _require_github_timestamp( + pr.get("updated_at"), f"pull_requests[{index}].updated_at" + ) + + base = _require_record(pr.get("base"), f"pull_requests[{index}].base") + base_ref = _require_text(base.get("ref"), f"pull_requests[{index}].base.ref") + resolved_base_sha = base_tips.get(base_ref) + if resolved_base_sha is None: + _fail(f"pull_requests[{index}].base.ref has no independently resolved base tip") + # The pull object can retain an older target SHA when its target branch advances. + # Validate that snapshot as untrusted input, but do not let it override the branch lookup. + _require_sha(base.get("sha"), f"pull_requests[{index}].base.sha") + + head = _require_record(pr.get("head"), f"pull_requests[{index}].head") + head_sha = _require_sha(head.get("sha"), f"pull_requests[{index}].head.sha") + + prior = existing.get(number) + if prior is None: + initial_train = UNTRIAGED_TRAIN + initial_disposition = "triage_required" + predecessor_prs: list[Any] = [] + overlap_prs: list[Any] = [] + successor_pr: int | None = None + else: + initial_train = _require_text(prior.get("initial_train"), "existing.initial_train") + initial_disposition = _require_text( + prior.get("initial_disposition"), "existing.initial_disposition" + ) + predecessor_prs = list( + _require_list(prior.get("predecessor_prs", []), "existing.predecessor_prs") + ) + overlap_prs = list(_require_list(prior.get("overlap_prs", []), "existing.overlap_prs")) + successor_pr = prior.get("successor_pr") + decision = _decision_metadata_for_live_head( + prior, + head_sha, + base_ref, + resolved_base_sha, + ) + + return { + "number": number, + "title": title, + "url": expected_url, + "initial_train": initial_train, + "initial_disposition": initial_disposition, + "base_ref": base_ref, + "base_sha": resolved_base_sha, + "head_sha": head_sha, + "head_sha_status": "exact_current_head", + "draft": draft, + "updated_at": updated_at, + "predecessor_prs": predecessor_prs, + "overlap_prs": overlap_prs, + "successor_pr": successor_pr, + **decision, + } + + +def build_refreshed_manifest( + seed: object, + live_result: object, + *, + base_sha: str, + snapshot_date: str, + base_tips: object | None = None, +) -> dict[str, Any]: + """Build a deterministic complete queue while preserving reviewed routing metadata.""" + try: + validate_manifest(seed) + except ManifestError as exc: + raise RefreshError(f"seed manifest is invalid: {exc}") from exc + seed_record = _require_record(seed, "seed") + live = _require_record(live_result, "live result") + if live.get("incomplete_results") is not False: + _fail("live pull-request inventory is incomplete") + pulls = _require_list(live.get("pull_requests"), "live result.pull_requests") + normalized_base_sha = _require_sha(base_sha, "base_sha") + normalized_base_tips = _normalize_base_tips(base_tips, normalized_base_sha) + try: + datetime.strptime(snapshot_date, "%Y-%m-%d") + except (TypeError, ValueError) as exc: + raise RefreshError("snapshot_date must use YYYY-MM-DD") from exc + + existing_items = _require_list(seed_record.get("pull_requests"), "seed.pull_requests") + existing: dict[int, dict[str, Any]] = {} + for index, raw_pr in enumerate(existing_items): + pr = _require_record(raw_pr, f"seed.pull_requests[{index}]") + number = _require_positive_int(pr.get("number"), f"seed.pull_requests[{index}].number") + existing[number] = pr + + seen: set[int] = set() + refreshed_items: list[dict[str, object]] = [] + for index, raw_pr in enumerate(pulls): + entry = _live_pr_entry( + raw_pr, + index=index, + base_tips=normalized_base_tips, + existing=existing, + ) + number = int(entry["number"]) + if number in seen: + _fail(f"duplicate pull request number: {number}") + seen.add(number) + refreshed_items.append(entry) + refreshed_items.sort(key=lambda item: int(item["number"])) + _invalidate_decisions_after_related_identity_movement(refreshed_items, existing) + + refreshed = deepcopy(seed_record) + trains = _require_record(refreshed.get("trains"), "seed.trains") + expected_untriaged = {"description": UNTRIAGED_DESCRIPTION, "issue": UNTRIAGED_ISSUE} + prior_untriaged = trains.get(UNTRIAGED_TRAIN) + if prior_untriaged is not None and prior_untriaged != expected_untriaged: + _fail(f"{UNTRIAGED_TRAIN} is already assigned to a different routing authority") + trains[UNTRIAGED_TRAIN] = expected_untriaged + refreshed["base_sha"] = normalized_base_sha + refreshed["snapshot_date"] = snapshot_date + refreshed["open_pr_count"] = len(refreshed_items) + refreshed["authority_note"] = ( + "Generated from a complete live GitHub open-PR inventory. Refresh checks, reviews, " + "threads, ancestry, and writer evidence immediately before action." + ) + refreshed["pull_requests"] = refreshed_items + try: + validate_manifest(refreshed) + except ManifestError as exc: + raise RefreshError(f"refreshed manifest is invalid: {exc}") from exc + return refreshed + + +def _request_github_json(target: str, token: str | None) -> tuple[object, str]: + """Read bounded JSON through a fixed GitHub host and repository-relative request target.""" + target = _require_text(target, "GitHub repository path") + if not target.startswith(REPOSITORY_API_PREFIX) or "://" in target or "\n" in target or "\r" in target: + _fail("GitHub repository path escaped the canonical repository") + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "bandscope-open-pr-queue-refresh", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + # Python >=3.12 verifies HTTPS certificates by default; the host and target + # are both fixed/validated above, so this generic legacy-version warning is + # a false positive for BandScope's declared runtime contract. + connection = http.client.HTTPSConnection( # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected + GITHUB_API_HOST, + timeout=20, + ) + try: + connection.request("GET", target, headers=headers) + response = connection.getresponse() + if response.status != 200: + _fail(f"GitHub API request failed with HTTP status {response.status}") + encoded = response.read(MAX_RESPONSE_BYTES + 1) + if len(encoded) > MAX_RESPONSE_BYTES: + _fail("GitHub API response exceeded the bounded response size") + payload = json.loads(encoded.decode("utf-8")) + link = response.getheader("Link", "") or "" + if not isinstance(link, str): + _fail("GitHub API Link header must be text") + except (OSError, UnicodeError, json.JSONDecodeError, http.client.HTTPException) as exc: + raise RefreshError(f"GitHub API request failed: {type(exc).__name__}") from exc + finally: + connection.close() + return payload, link + + +def fetch_live_branch_sha(branch_ref: str, token: str | None) -> str: + """Resolve one current same-repository base branch tip through a fixed API authority.""" + normalized_ref = _require_text(branch_ref, "branch_ref") + encoded_ref = urllib.parse.quote(normalized_ref, safe="") + target = f"{REPOSITORY_API_PREFIX}branches/{encoded_ref}" + payload, _ = _request_github_json(target, token) + branch = _require_record(payload, "branch") + commit = _require_record(branch.get("commit"), "branch.commit") + return _require_sha(commit.get("sha"), "branch.commit.sha") + + +def fetch_live_branch_ref_page( + page: int, + page_size: int, + token: str | None, +) -> tuple[list[dict[str, Any]], bool]: + """Fetch one bounded page of repository branch refs for exact target-tip resolution.""" + _require_positive_int(page, "page") + _require_positive_int(page_size, "page_size") + query = urllib.parse.urlencode({"per_page": page_size, "page": page}) + target = f"{REPOSITORY_API_PREFIX}git/matching-refs/heads/?{query}" + payload, link = _request_github_json(target, token) + items = _require_list(payload, f"branch-ref page {page}") + if any(not isinstance(item, dict) for item in items): + _fail(f"branch-ref page {page} must contain objects") + return items, 'rel="next"' in link + + +def fetch_live_branch_index(token: str | None) -> dict[str, str]: + """Resolve all branch tips from a complete bounded Git refs inventory.""" + refs = collect_paginated_branch_refs( + lambda page, size: fetch_live_branch_ref_page(page, size, token) + ) + branch_index: dict[str, str] = {} + for index, raw_ref in enumerate(refs): + ref_record = _require_record(raw_ref, f"branch snapshot[{index}]") + full_ref = _require_text(ref_record.get("ref"), f"branch snapshot[{index}].ref") + if not full_ref.startswith(BRANCH_REF_PREFIX) or full_ref == BRANCH_REF_PREFIX: + _fail(f"branch snapshot[{index}].ref must be a refs/heads branch") + branch_ref = full_ref[len(BRANCH_REF_PREFIX) :] + target_object = _require_record( + ref_record.get("object"), f"branch snapshot[{index}].object" + ) + if target_object.get("type") != "commit": + _fail(f"branch snapshot[{index}].object.type must be commit") + branch_sha = _require_sha( + target_object.get("sha"), f"branch snapshot[{index}].object.sha" + ) + if branch_ref in branch_index: + _fail(f"branch snapshot contains duplicate branch ref: {branch_ref}") + branch_index[branch_ref] = branch_sha + if BASE_BRANCH not in branch_index: + _fail(f"live branch snapshot is missing protected base {BASE_BRANCH}") + return branch_index + + +def fetch_live_base_sha(token: str | None) -> str: + """Resolve the current protected develop tip for backward-compatible callers.""" + return fetch_live_branch_sha(BASE_BRANCH, token) + + +def fetch_live_pull_page( + page: int, + page_size: int, + token: str | None, +) -> tuple[list[dict[str, Any]], bool]: + """Fetch one bounded page of all open PRs so stacked bases remain in the queue.""" + _require_positive_int(page, "page") + _require_positive_int(page_size, "page_size") + query = urllib.parse.urlencode( + { + "state": "open", + "per_page": page_size, + "page": page, + "sort": "created", + "direction": "asc", + } + ) + target = f"{REPOSITORY_API_PREFIX}pulls?{query}" + payload, link = _request_github_json(target, token) + items = _require_list(payload, f"pull request page {page}") + if any(not isinstance(item, dict) for item in items): + _fail(f"pull request page {page} must contain objects") + return items, 'rel="next"' in link + + +def resolve_live_base_tips(live_result: object, token: str | None) -> dict[str, str]: + """Select every current PR target from one independently resolved branch inventory.""" + live = _require_record(live_result, "live result") + if live.get("incomplete_results") is not False: + _fail("live pull-request inventory is incomplete") + pulls = _require_list(live.get("pull_requests"), "live result.pull_requests") + base_refs: set[str] = {BASE_BRANCH} + for index, raw_pr in enumerate(pulls): + pr = _require_record(raw_pr, f"pull_requests[{index}]") + base = _require_record(pr.get("base"), f"pull_requests[{index}].base") + base_refs.add(_require_text(base.get("ref"), f"pull_requests[{index}].base.ref")) + + branch_index = fetch_live_branch_index(token) + missing = sorted(base_refs - branch_index.keys()) + if missing: + _fail(f"target base ref is absent from the live branch snapshot: {missing[0]}") + return {branch_ref: branch_index[branch_ref] for branch_ref in sorted(base_refs)} + + +def _require_safe_publication_parent(path: Path, repository_root: Path) -> None: + """Reject publication paths whose repository-owned parent chain contains symlinks.""" + try: + relative = path.relative_to(repository_root) + except ValueError: + _fail("open PR queue manifest path escaped the repository root") + if len(relative.parts) < 2: + _fail("open PR queue manifest path must have a repository-owned parent") + + current = repository_root + for component in relative.parts[:-1]: + current = current / component + if current.is_symlink(): + _fail("open PR queue manifest parent must not contain symbolic links") + if not current.is_dir(): + _fail("open PR queue manifest parent must be an existing directory") + + +def _write_manifest_atomic(manifest: dict[str, Any]) -> None: + """Atomically replace the canonical manifest without following a symlink target.""" + _require_safe_publication_parent(MANIFEST_PATH, REPO_ROOT) + if MANIFEST_PATH.is_symlink(): + _fail("open PR queue manifest path must not be a symbolic link") + temporary = MANIFEST_PATH.with_name(f".{MANIFEST_PATH.name}.tmp") + if temporary.exists() or temporary.is_symlink(): + _fail("temporary manifest path already exists") + encoded = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + try: + with temporary.open("x", encoding="utf-8", newline="\n") as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, MANIFEST_PATH) + finally: + if temporary.exists(): + temporary.unlink() + + +def main() -> int: + """Refresh the committed queue from GitHub and return a shell-friendly status code.""" + try: + seed = load_manifest(MANIFEST_PATH) + token = os.environ.get("GITHUB_TOKEN") + live_result = collect_paginated_pulls( + lambda page, size: fetch_live_pull_page(page, size, token) + ) + base_tips = resolve_live_base_tips(live_result, token) + base_sha = base_tips[BASE_BRANCH] + snapshot_date = datetime.now(ZoneInfo("Asia/Seoul")).date().isoformat() + refreshed = build_refreshed_manifest( + seed, + live_result, + base_sha=base_sha, + snapshot_date=snapshot_date, + base_tips=base_tips, + ) + _write_manifest_atomic(refreshed) + except (ManifestError, RefreshError) as exc: + print(f"open PR queue refresh failed: {exc}", file=sys.stderr) + return 1 + print( + f"open PR queue refreshed: {refreshed['open_pr_count']} PRs at {refreshed['base_sha']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/render_open_pr_queue.py b/scripts/checks/render_open_pr_queue.py new file mode 100644 index 000000000..490142b82 --- /dev/null +++ b/scripts/checks/render_open_pr_queue.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Render the BandScope merge-train manifest into a deterministic human view.""" + +from __future__ import annotations + +import argparse +import html +import os +import sys +from pathlib import Path +from typing import Any + +from verify_open_pr_queue import ( + DEFAULT_MANIFEST_PATH, + ManifestError, + load_manifest, + validate_manifest, +) + +REPO_ROOT = Path(__file__).resolve().parents[2] +HUMAN_VIEW_PATH = REPO_ROOT / "docs" / "product-readiness" / "open-pr-queue.md" + + +class RenderError(ValueError): + """Raised when the generated queue view cannot be published safely.""" + + +def _escape_cell(value: object) -> str: + """Render untrusted GitHub text as inert single-line Markdown table data.""" + text = str(value).replace("\r", " ").replace("\n", " ") + return html.escape(text, quote=True).replace("|", r"\|") + + +def _pr_links(numbers: object) -> str: + """Render a validated PR-number list without introducing external link authority.""" + if not isinstance(numbers, list): + raise RenderError("pull-request relation must be an array") + return ", ".join(f"#{number}" for number in numbers) if numbers else "—" + + +def render_queue_markdown(manifest: object) -> str: + """Render one complete validated manifest without claiming merge readiness.""" + validate_manifest(manifest) + if not isinstance(manifest, dict): + raise ManifestError("manifest must be an object") + + trains = manifest["trains"] + pull_requests = manifest["pull_requests"] + assert isinstance(trains, dict) + assert isinstance(pull_requests, list) + + lines = [ + "# BandScope open PR merge-train view", + "", + "> This is capture-time routing evidence generated from the machine-readable queue. " + "It is not merge-readiness evidence. Refresh exact-head checks, reviews, unresolved " + "threads, ancestry, mergeability, security findings, and active-writer state immediately " + "before any material action.", + "", + f"- Snapshot: **{_escape_cell(manifest['snapshot_date'])}** " + f"({_escape_cell(manifest['timezone'])})", + f"- Protected target captured: " + f"`{_escape_cell(manifest['base_branch'])}@{_escape_cell(manifest['base_sha'])}`", + f"- Open PRs: **{manifest['open_pr_count']}**", + f"- Authority note: {_escape_cell(manifest['authority_note'])}", + "", + ] + + prs_by_train: dict[str, list[dict[str, Any]]] = {} + for raw_pr in pull_requests: + assert isinstance(raw_pr, dict) + train_name = str(raw_pr["initial_train"]) + prs_by_train.setdefault(train_name, []).append(raw_pr) + + for train_name in sorted(trains): + raw_train = trains[train_name] + assert isinstance(raw_train, dict) + train_prs = sorted(prs_by_train.get(train_name, []), key=lambda item: int(item["number"])) + lines.extend( + [ + f"## {_escape_cell(train_name)} — {_escape_cell(raw_train['description'])}", + "", + f"Canonical issue: #{raw_train['issue']}", + "", + "| PR | Title | Disposition | Base | Exact head | Predecessors | Successor | Overlaps |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + if not train_prs: + lines.append("| — | — | — | — | — | — | — | — |") + else: + for pr in train_prs: + number = int(pr["number"]) + url = str(pr["url"]) + base_ref = pr.get("base_ref") + base_sha = pr.get("base_sha") + base_cell = ( + f"`{_escape_cell(base_ref)}@{_escape_cell(str(base_sha)[:12])}`" + if base_ref is not None and base_sha is not None + else "refresh required" + ) + head_sha = pr.get("head_sha") + head_cell = ( + f"`{_escape_cell(str(head_sha)[:12])}`" + if head_sha is not None + else "refresh required" + ) + successor = pr.get("successor_pr") + successor_cell = f"#{successor}" if successor is not None else "—" + lines.append( + "| " + f"[#{number}]({url}) | " + f"{_escape_cell(pr['title'])} | " + f"`{_escape_cell(pr['initial_disposition'])}` | " + f"{base_cell} | " + f"{head_cell} | " + f"{_pr_links(pr.get('predecessor_prs', []))} | " + f"{successor_cell} | " + f"{_pr_links(pr.get('overlap_prs', []))} |" + ) + lines.append("") + + orphan_trains = sorted(set(prs_by_train) - set(trains)) + if orphan_trains: + raise RenderError(f"validated manifest contains unknown train: {orphan_trains[0]}") + + return "\n".join(lines).rstrip() + "\n" + + +def _require_safe_publication_parent(path: Path, publication_root: Path) -> None: + """Reject generated output whose trusted parent chain contains a symlink.""" + if publication_root.is_symlink() or not publication_root.is_dir(): + raise RenderError("human-view publication root must be an existing non-symlink directory") + try: + relative = path.relative_to(publication_root) + except ValueError as exc: + raise RenderError("human-view path escaped its publication root") from exc + + current = publication_root + for component in relative.parts[:-1]: + current = current / component + if current.is_symlink(): + raise RenderError("human-view parent must not contain symbolic links") + if not current.is_dir(): + raise RenderError("human-view parent must be an existing directory") + + +def write_human_view_atomic( + content: str, + path: Path = HUMAN_VIEW_PATH, + *, + publication_root: Path = REPO_ROOT, +) -> None: + """Publish generated Markdown atomically without following symlink authority.""" + _require_safe_publication_parent(path, publication_root) + if path.is_symlink(): + raise RenderError("human-view path must not be a symbolic link") + temporary = path.with_name(f".{path.name}.tmp") + if temporary.exists() or temporary.is_symlink(): + raise RenderError("temporary human-view path already exists") + + try: + with temporary.open("x", encoding="utf-8", newline="\n") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except OSError as exc: + raise RenderError(f"unable to publish human view: {type(exc).__name__}") from exc + finally: + if temporary.exists(): + temporary.unlink() + + +def _check_current(rendered: str, path: Path) -> bool: + """Return whether the committed human view is byte-for-byte generated truth.""" + try: + return path.read_text(encoding="utf-8") == rendered and not path.is_symlink() + except (OSError, UnicodeError): + return False + + +def main(argv: list[str] | None = None) -> int: + """Render or verify the queue's generated human-readable projection.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + action="store_true", + help="fail if the committed human view differs from the validated manifest", + ) + args = parser.parse_args(argv) + + try: + manifest = load_manifest(DEFAULT_MANIFEST_PATH) + rendered = render_queue_markdown(manifest) + if args.check: + if not _check_current(rendered, HUMAN_VIEW_PATH): + print("open PR queue human view is stale", file=sys.stderr) + return 1 + else: + write_human_view_atomic(rendered, HUMAN_VIEW_PATH) + except (ManifestError, RenderError) as exc: + print(f"open PR queue human-view generation failed: {exc}", file=sys.stderr) + return 1 + + print("open PR queue human view verified" if args.check else "open PR queue human view refreshed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/verify_open_pr_queue.py b/scripts/checks/verify_open_pr_queue.py new file mode 100644 index 000000000..0e87a200a --- /dev/null +++ b/scripts/checks/verify_open_pr_queue.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +"""Fail closed when the BandScope product-readiness pull-request queue is inconsistent.""" + +from __future__ import annotations + +import json +import re +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_MANIFEST_PATH = REPO_ROOT / "docs" / "product-readiness" / "open-pr-queue.json" +REPOSITORY = "ContextualWisdomLab/bandscope" +BASE_BRANCH = "develop" +SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) +ROOT_FIELDS = frozenset( + { + "schema_version", + "snapshot_date", + "timezone", + "repository", + "base_branch", + "base_sha", + "open_pr_count", + "authority_note", + "trains", + "pull_requests", + } +) +TRAIN_FIELDS = frozenset({"description", "issue"}) +PULL_REQUEST_FIELDS = frozenset( + { + "number", + "title", + "url", + "initial_train", + "initial_disposition", + "base_ref", + "base_sha", + "head_sha", + "head_sha_status", + "draft", + "updated_at", + "predecessor_prs", + "overlap_prs", + "successor_pr", + "disposition", + "decision_timestamp", + "decision_rationale", + "decision_owner", + } +) +ALLOWED_INITIAL_DISPOSITIONS = frozenset( + { + "analysis_failure_next_action_copy", + "canonical_audio_resource_policy", + "canonical_dependency_security_base", + "canonical_local_audio_resource_policy", + "canonical_python_branch_coverage_base", + "figma_contract_inventory_drift_check", + "first_run_analyze_entry", + "first_run_own_song_action", + "help_next_action_copy", + "inbound_handoff_reanalysis_slice", + "licensed_demo_first_run_activation", + "local_intake_failure_next_action_copy", + "observation_probability_vectorization", + "outbound_naruon_handoff_contract", + "player_first_section_loop", + "product_readiness_baseline_program", + "project_atomic_publication", + "project_save_load_failure_next_action_copy", + "real_audio_accuracy_slice_under_issue_770", + "storybook_design_inventory_slice", + "support_manifest_boundary", + "triage_required", + "workspace_rehearsal_map_slice", + "youtube_import_failure_next_action_copy", + } +) +ALLOWED_DISPOSITIONS = frozenset( + { + "canonical_active", + "stacked_after", + "refresh_required", + "superseded_by", + "duplicate_of", + "invalid_or_out_of_scope", + "blocked_by_external_owner", + } +) +DECISION_FIELDS = frozenset( + {"disposition", "decision_timestamp", "decision_rationale", "decision_owner"} +) + + +class ManifestError(ValueError): + """Raised when queue evidence is structurally inconsistent or ambiguous.""" + + +def _fail(message: str) -> None: + """Raise the stable manifest-validation exception.""" + raise ManifestError(message) + + +def _require_record(value: object, field: str) -> dict[str, Any]: + """Return a JSON object or fail with a field-specific diagnostic.""" + if not isinstance(value, dict): + _fail(f"{field} must be an object") + return value + + +def _reject_unknown_fields(record: dict[str, Any], allowed: frozenset[str], field: str) -> None: + """Reject evidence fields that are not part of the reviewed manifest schema.""" + unsupported = sorted(set(record) - allowed) + if unsupported: + _fail(f"{field} has unsupported field: {unsupported[0]}") + + +def _require_list(value: object, field: str) -> list[Any]: + """Return a JSON array or fail with a field-specific diagnostic.""" + if not isinstance(value, list): + _fail(f"{field} must be an array") + return value + + +def _require_non_empty_string(value: object, field: str) -> str: + """Return a non-empty string without coercing queue evidence.""" + if not isinstance(value, str) or not value.strip() or value != value.strip(): + _fail(f"{field} must be a non-empty trim-stable string") + return value + + +def _require_sha(value: object, field: str) -> str: + """Return an immutable 40-hex Git commit identity.""" + text = _require_non_empty_string(value, field) + if SHA_PATTERN.fullmatch(text) is None: + _fail(f"{field} must be 40 hexadecimal characters") + return text.lower() + + +def _require_github_timestamp(value: object, field: str) -> str: + """Return a strict UTC GitHub timestamp so freshness evidence is unambiguous.""" + text = _require_non_empty_string(value, field) + try: + datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as exc: + raise ManifestError(f"{field} must use YYYY-MM-DDTHH:MM:SSZ") from exc + return text + + +def _require_pr_numbers(value: object, field: str) -> list[int]: + """Return a duplicate-free list of positive PR identities.""" + pr_numbers = _require_list(value, field) + normalized: list[int] = [] + seen: set[int] = set() + for index, pr_number in enumerate(pr_numbers): + if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number <= 0: + _fail(f"{field}[{index}] must be a positive integer") + if pr_number in seen: + _fail(f"{field} contains duplicate pull request: {pr_number}") + seen.add(pr_number) + normalized.append(pr_number) + return normalized + + +def _require_optional_pr_number(value: object, field: str) -> int | None: + """Return a nullable positive PR identity without accepting booleans.""" + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + _fail(f"{field} must be null or a positive integer") + return value + + +def _validate_reviewed_disposition( + pr: dict[str, Any], + *, + prefix: str, + predecessors: list[int], + successor: int | None, +) -> None: + """Validate optional legacy-compatible reviewed routing without inventing decisions.""" + present = DECISION_FIELDS.intersection(pr) + if not present: + return + if "disposition" not in pr: + _fail(f"{prefix}.disposition is required when decision metadata is present") + + disposition = _require_non_empty_string(pr.get("disposition"), f"{prefix}.disposition") + if disposition not in ALLOWED_DISPOSITIONS: + _fail(f"{prefix}.disposition is unsupported: {disposition}") + + if disposition == "refresh_required": + missing = DECISION_FIELDS - set(pr) + if missing: + _fail(f"{prefix} refresh_required decision must include explicit null metadata") + for field in ("decision_timestamp", "decision_rationale", "decision_owner"): + if pr.get(field) is not None: + _fail(f"{prefix}.{field} must be null while disposition is refresh_required") + else: + missing = DECISION_FIELDS - set(pr) + if missing: + _fail(f"{prefix} reviewed decision metadata is incomplete") + _require_github_timestamp(pr.get("decision_timestamp"), f"{prefix}.decision_timestamp") + _require_non_empty_string(pr.get("decision_rationale"), f"{prefix}.decision_rationale") + _require_non_empty_string(pr.get("decision_owner"), f"{prefix}.decision_owner") + + if disposition == "stacked_after" and not predecessors: + _fail(f"{prefix}.stacked_after requires at least one predecessor_prs entry") + if disposition in {"superseded_by", "duplicate_of"} and successor is None: + _fail(f"{prefix}.{disposition} requires successor_pr") + + +def _validate_predecessor_graph( + predecessors_by_pr: dict[int, list[int]], known_prs: set[int] +) -> None: + """Reject unknown dependency identities and directed predecessor cycles.""" + for number, predecessors in predecessors_by_pr.items(): + for predecessor in predecessors: + if predecessor not in known_prs: + _fail(f"pull request {number} references unknown predecessor: {predecessor}") + if predecessor == number: + _fail(f"pull request {number} has a predecessor cycle") + + visiting: set[int] = set() + visited: set[int] = set() + + def visit(number: int) -> None: + if number in visited: + return + if number in visiting: + _fail(f"predecessor cycle detected at pull request {number}") + visiting.add(number) + for predecessor in predecessors_by_pr.get(number, []): + visit(predecessor) + visiting.remove(number) + visited.add(number) + + for number in known_prs: + visit(number) + + +def _validate_overlap_and_successor_graph( + overlaps_by_pr: dict[int, list[int]], + successor_by_pr: dict[int, int | None], + known_prs: set[int], +) -> None: + """Require symmetric overlap evidence and an acyclic explicit succession relation.""" + for number, overlaps in overlaps_by_pr.items(): + for overlap in overlaps: + if overlap not in known_prs: + _fail(f"pull request {number} references unknown overlap: {overlap}") + if overlap == number: + _fail(f"pull request {number} cannot overlap itself") + if number not in overlaps_by_pr.get(overlap, []): + _fail(f"pull request overlap must be symmetric: {number} <-> {overlap}") + + for number, successor in successor_by_pr.items(): + if successor is None: + continue + if successor not in known_prs: + _fail(f"pull request {number} references unknown successor: {successor}") + if successor == number: + _fail(f"pull request {number} cannot succeed itself") + if successor not in overlaps_by_pr.get(number, []): + _fail(f"pull request {number} successor_pr must also be declared in overlap_prs") + + visiting: set[int] = set() + visited: set[int] = set() + + def visit(number: int) -> None: + if number in visited: + return + if number in visiting: + _fail(f"successor cycle detected at pull request {number}") + visiting.add(number) + successor = successor_by_pr.get(number) + if successor is not None: + visit(successor) + visiting.remove(number) + visited.add(number) + + for number in known_prs: + visit(number) + + +def validate_manifest(manifest: object) -> None: + """Validate intrinsic queue invariants without treating the seed as live GitHub evidence.""" + root = _require_record(manifest, "manifest") + _reject_unknown_fields(root, ROOT_FIELDS, "manifest") + if root.get("schema_version") != "1.0.0": + _fail("schema_version must be 1.0.0") + if root.get("repository") != REPOSITORY: + _fail(f"repository must be {REPOSITORY}") + if root.get("base_branch") != BASE_BRANCH: + _fail(f"base_branch must be {BASE_BRANCH}") + _require_sha(root.get("base_sha"), "base_sha") + _require_non_empty_string(root.get("snapshot_date"), "snapshot_date") + _require_non_empty_string(root.get("timezone"), "timezone") + _require_non_empty_string(root.get("authority_note"), "authority_note") + + trains = _require_record(root.get("trains"), "trains") + if not trains: + _fail("trains must not be empty") + for train_name, raw_train in trains.items(): + _require_non_empty_string(train_name, "train name") + train = _require_record(raw_train, f"trains.{train_name}") + _reject_unknown_fields(train, TRAIN_FIELDS, f"trains.{train_name}") + _require_non_empty_string(train.get("description"), f"trains.{train_name}.description") + issue = train.get("issue") + if isinstance(issue, bool) or not isinstance(issue, int) or issue <= 0: + _fail(f"trains.{train_name}.issue must be a positive integer") + + pull_requests = _require_list(root.get("pull_requests"), "pull_requests") + open_pr_count = root.get("open_pr_count") + if isinstance(open_pr_count, bool) or not isinstance(open_pr_count, int): + _fail("open_pr_count must be an integer") + if open_pr_count != len(pull_requests): + _fail( + f"open_pr_count must equal pull_requests length: {open_pr_count} != {len(pull_requests)}" + ) + + seen_numbers: set[int] = set() + predecessors_by_pr: dict[int, list[int]] = {} + overlaps_by_pr: dict[int, list[int]] = {} + successor_by_pr: dict[int, int | None] = {} + for index, raw_pr in enumerate(pull_requests): + prefix = f"pull_requests[{index}]" + pr = _require_record(raw_pr, prefix) + _reject_unknown_fields(pr, PULL_REQUEST_FIELDS, prefix) + number = pr.get("number") + if isinstance(number, bool) or not isinstance(number, int) or number <= 0: + _fail(f"{prefix}.number must be a positive integer") + if number in seen_numbers: + _fail(f"duplicate pull request number: {number}") + seen_numbers.add(number) + predecessors = _require_pr_numbers( + pr.get("predecessor_prs", []), f"{prefix}.predecessor_prs" + ) + overlaps = _require_pr_numbers(pr.get("overlap_prs", []), f"{prefix}.overlap_prs") + successor = _require_optional_pr_number(pr.get("successor_pr"), f"{prefix}.successor_pr") + predecessors_by_pr[number] = predecessors + overlaps_by_pr[number] = overlaps + successor_by_pr[number] = successor + + _require_non_empty_string(pr.get("title"), f"{prefix}.title") + expected_url = f"https://github.com/{REPOSITORY}/pull/{number}" + if pr.get("url") != expected_url: + _fail(f"{prefix}.url must be {expected_url}") + + train_name = _require_non_empty_string(pr.get("initial_train"), f"{prefix}.initial_train") + if train_name not in trains: + _fail(f"{prefix}.initial_train references unknown train: {train_name}") + initial_disposition = _require_non_empty_string( + pr.get("initial_disposition"), f"{prefix}.initial_disposition" + ) + if initial_disposition not in ALLOWED_INITIAL_DISPOSITIONS: + _fail(f"{prefix}.initial_disposition is unsupported: {initial_disposition}") + + base_ref = pr.get("base_ref") + pr_base_sha = pr.get("base_sha") + if (base_ref is None) != (pr_base_sha is None): + _fail(f"{prefix}.base_ref and {prefix}.base_sha must be present together") + if base_ref is not None: + _require_non_empty_string(base_ref, f"{prefix}.base_ref") + _require_sha(pr_base_sha, f"{prefix}.base_sha") + + if "head_sha" not in pr: + _fail(f"{prefix}.head_sha is required") + head_sha = pr["head_sha"] + head_status = pr.get("head_sha_status") + if head_sha is None: + if head_status != "refresh_required_before_action": + _fail( + f"{prefix}.head_sha_status must be refresh_required_before_action when head_sha is null" + ) + else: + _require_sha(head_sha, f"{prefix}.head_sha") + if head_status != "exact_current_head": + _fail(f"{prefix}.head_sha_status must be exact_current_head when head_sha is present") + + if "draft" in pr and not isinstance(pr["draft"], bool): + _fail(f"{prefix}.draft must be boolean") + if "updated_at" in pr: + _require_github_timestamp(pr["updated_at"], f"{prefix}.updated_at") + + _validate_reviewed_disposition( + pr, + prefix=prefix, + predecessors=predecessors, + successor=successor, + ) + + _validate_predecessor_graph(predecessors_by_pr, seen_numbers) + _validate_overlap_and_successor_graph(overlaps_by_pr, successor_by_pr, seen_numbers) + + +def load_manifest(path: Path = DEFAULT_MANIFEST_PATH) -> object: + """Load the queue JSON without accepting duplicate object keys silently.""" + + def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + _fail(f"duplicate JSON object key: {key}") + result[key] = value + return result + + try: + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=reject_duplicate_keys) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ManifestError(f"unable to read open PR queue manifest: {type(exc).__name__}") from exc + + +def main() -> int: + """Validate the committed seed and return a shell-friendly status code.""" + try: + validate_manifest(load_manifest()) + except ManifestError as exc: + print(f"open PR queue verification failed: {exc}", file=sys.stderr) + return 1 + print("open PR queue verification passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/verify_open_pr_readiness.py b/scripts/checks/verify_open_pr_readiness.py new file mode 100755 index 000000000..16e3c8584 --- /dev/null +++ b/scripts/checks/verify_open_pr_readiness.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Verify a previously collected BandScope exact-head PR readiness document offline.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from collect_open_pr_readiness import ReadinessError, validate_readiness_document + +DEFAULT_PATH = Path("docs/product-readiness/open-pr-readiness.json") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("path", nargs="?", type=Path, default=DEFAULT_PATH) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + document = json.loads(args.path.read_text(encoding="utf-8")) + validate_readiness_document(document) + except (OSError, UnicodeError, json.JSONDecodeError, ReadinessError) as exc: + print(f"open PR readiness verification failed: {exc}", file=sys.stderr) + return 1 + print("open PR readiness verification passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/harness/quickcheck.sh b/scripts/harness/quickcheck.sh index 185fe7aba..50417ae3c 100755 --- a/scripts/harness/quickcheck.sh +++ b/scripts/harness/quickcheck.sh @@ -6,6 +6,7 @@ cd "$REPO_ROOT" python3 scripts/checks/test_verify_docs.py python3 scripts/checks/verify_docs.py +python3 scripts/checks/verify_open_pr_queue.py python3 scripts/checks/verify_security_notes.py python3 scripts/checks/security_gates.py python3 scripts/checks/verify_supply_chain.py diff --git a/services/analysis-engine/tests/test_open_pr_queue_base_movement.py b/services/analysis-engine/tests/test_open_pr_queue_base_movement.py new file mode 100644 index 000000000..6eef5f186 --- /dev/null +++ b/services/analysis-engine/tests/test_open_pr_queue_base_movement.py @@ -0,0 +1,99 @@ +"""Regression coverage for reviewed queue decisions after target-base movement.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +REPO_ROOT = Path(__file__).resolve().parents[3] +REFRESHER_PATH = REPO_ROOT / "scripts" / "checks" / "refresh_open_pr_queue.py" + + +def _load_refresher() -> ModuleType: + """Load the queue refresher from its repository-owned operator path.""" + sys.path.insert(0, str(REFRESHER_PATH.parent)) + try: + spec = importlib.util.spec_from_file_location("refresh_open_pr_queue", REFRESHER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +def _reviewed_seed() -> dict[str, object]: + """Return one reviewed PR whose decision was made against the old base tip.""" + old_base = "a" * 40 + return { + "schema_version": "1.0.0", + "snapshot_date": "2026-09-02", + "timezone": "Asia/Seoul", + "repository": "ContextualWisdomLab/bandscope", + "base_branch": "develop", + "base_sha": old_base, + "open_pr_count": 1, + "authority_note": "Refresh exact live evidence before action.", + "trains": {"T0": {"description": "Queue control", "issue": 966}}, + "pull_requests": [ + { + "number": 968, + "title": "queue control", + "url": "https://github.com/ContextualWisdomLab/bandscope/pull/968", + "initial_train": "T0", + "initial_disposition": "product_readiness_baseline_program", + "base_ref": "develop", + "base_sha": old_base, + "head_sha": "b" * 40, + "head_sha_status": "exact_current_head", + "draft": False, + "updated_at": "2026-09-02T02:31:00Z", + "predecessor_prs": [], + "overlap_prs": [], + "successor_pr": None, + "disposition": "canonical_active", + "decision_timestamp": "2026-09-02T02:30:00Z", + "decision_rationale": "Canonical executable owner for issue #966.", + "decision_owner": "issue:#966", + } + ], + } + + +def test_live_refresh_invalidates_reviewed_decision_when_base_tip_moves() -> None: + """A stable PR head cannot retain routing evidence reviewed against another base tree.""" + refresher = _load_refresher() + new_base = "c" * 40 + live = { + "incomplete_results": False, + "pull_requests": [ + { + "number": 968, + "title": "queue control", + "html_url": "https://github.com/ContextualWisdomLab/bandscope/pull/968", + "state": "open", + "draft": False, + "updated_at": "2026-09-02T03:31:00Z", + "base": {"ref": "develop", "sha": "a" * 40}, + "head": {"sha": "b" * 40}, + } + ], + } + + refreshed = refresher.build_refreshed_manifest( + _reviewed_seed(), + live, + base_sha=new_base, + snapshot_date="2026-09-02", + base_tips={"develop": new_base}, + ) + + entry = refreshed["pull_requests"][0] + assert entry["head_sha"] == "b" * 40 + assert entry["base_sha"] == new_base + assert entry["disposition"] == "refresh_required" + assert entry["decision_timestamp"] is None + assert entry["decision_rationale"] is None + assert entry["decision_owner"] is None diff --git a/services/analysis-engine/tests/test_open_pr_queue_branch_pagination.py b/services/analysis-engine/tests/test_open_pr_queue_branch_pagination.py new file mode 100644 index 000000000..8b60f4c57 --- /dev/null +++ b/services/analysis-engine/tests/test_open_pr_queue_branch_pagination.py @@ -0,0 +1,118 @@ +"""Regression tests for bounded pagination of live branch-tip inventory.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +REFRESHER_PATH = REPO_ROOT / "scripts" / "checks" / "refresh_open_pr_queue.py" + + +def _load_refresher() -> ModuleType: + """Load the queue refresher without making scripts a Python package.""" + sys.path.insert(0, str(REFRESHER_PATH.parent)) + try: + spec = importlib.util.spec_from_file_location( + "refresh_open_pr_queue_branch_pagination", REFRESHER_PATH + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +def _branch_ref(branch_ref: str, sha: str) -> dict[str, object]: + """Return one matching-refs API branch record.""" + return { + "ref": f"refs/heads/{branch_ref}", + "object": {"type": "commit", "sha": sha}, + } + + +def test_branch_index_follows_matching_refs_pagination( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A target branch present only after page one must still resolve exactly.""" + refresher = _load_refresher() + observed: list[str] = [] + + def fake_request(target: str, token: str | None) -> tuple[object, str]: + observed.append(target) + if target.endswith("?per_page=100&page=1"): + return ( + [_branch_ref("develop", "d" * 40)], + '; rel="next"', + ) + if target.endswith("?per_page=100&page=2"): + return ( + [_branch_ref("docs/gap-baseline-2026-08-31", "e" * 40)], + "", + ) + raise AssertionError(f"unexpected GitHub target: {target}") + + monkeypatch.setattr(refresher, "_request_github_json", fake_request) + + branch_index = refresher.fetch_live_branch_index(None) + + assert branch_index == { + "develop": "d" * 40, + "docs/gap-baseline-2026-08-31": "e" * 40, + } + assert observed == [ + "/repos/ContextualWisdomLab/bandscope/git/matching-refs/heads/?per_page=100&page=1", + "/repos/ContextualWisdomLab/bandscope/git/matching-refs/heads/?per_page=100&page=2", + ] + + +def test_branch_ref_collector_fails_closed_when_bound_would_truncate() -> None: + """An announced page beyond the bound cannot yield a partial branch authority set.""" + refresher = _load_refresher() + + with pytest.raises(refresher.RefreshError, match="branch-ref pagination bound"): + refresher.collect_paginated_branch_refs( + lambda page, size: ([_branch_ref(f"branch-{page}", f"{page:x}".rjust(40, "0"))], True), + page_size=1, + max_pages=2, + ) + + +@pytest.mark.parametrize( + ("page_result", "expected"), + [ + (("not-a-list", False), "must contain objects"), + (([object()], False), "must contain objects"), + (([_branch_ref("develop", "d" * 40)], "yes"), "next-page marker"), + (([], True), "empty but announces another page"), + ], +) +def test_branch_ref_collector_rejects_malformed_pagination( + page_result: tuple[object, object], expected: str +) -> None: + """Malformed page payloads and pagination metadata fail before publication.""" + refresher = _load_refresher() + + with pytest.raises(refresher.RefreshError, match=expected): + refresher.collect_paginated_branch_refs(lambda page, size: page_result) + + +def test_branch_ref_page_rejects_non_record_items( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The network adapter rejects a JSON array containing non-record ref entries.""" + refresher = _load_refresher() + + monkeypatch.setattr( + refresher, + "_request_github_json", + lambda target, token: (["not-a-ref-record"], ""), + ) + + with pytest.raises(refresher.RefreshError, match="must contain objects"): + refresher.fetch_live_branch_ref_page(1, 100, None) diff --git a/services/analysis-engine/tests/test_open_pr_queue_human_view.py b/services/analysis-engine/tests/test_open_pr_queue_human_view.py new file mode 100644 index 000000000..749ab9946 --- /dev/null +++ b/services/analysis-engine/tests/test_open_pr_queue_human_view.py @@ -0,0 +1,160 @@ +"""Regression tests for the generated human view of the BandScope merge-train queue.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +RENDERER_PATH = REPO_ROOT / "scripts" / "checks" / "render_open_pr_queue.py" + + +def _load_renderer() -> ModuleType: + """Load the queue renderer without requiring scripts to be a Python package.""" + sys.path.insert(0, str(RENDERER_PATH.parent)) + try: + spec = importlib.util.spec_from_file_location("render_open_pr_queue", RENDERER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +def _manifest() -> dict[str, object]: + """Return a minimal valid queue with two trains and one stack edge.""" + return { + "schema_version": "1.0.0", + "snapshot_date": "2026-09-01", + "timezone": "Asia/Seoul", + "repository": "ContextualWisdomLab/bandscope", + "base_branch": "develop", + "base_sha": "a" * 40, + "open_pr_count": 2, + "authority_note": ( + "Generated from a complete live GitHub open-PR inventory. Refresh checks, reviews, " + "threads, ancestry, and writer evidence immediately before action." + ), + "trains": { + "T0": {"description": "Dependency | base ", "issue": 966}, + "T3": {"description": "Active player", "issue": 961}, + }, + "pull_requests": [ + { + "number": 968, + "title": "queue | renderer