diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000000..596f67845d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,84 @@ +name: Bug report +description: Report incorrect CodexBar behavior with safe, reproducible evidence. +labels: [bug, needs-triage] +body: + - type: markdown + attributes: + value: | + Do not paste API keys, cookies, Authorization headers, browser databases, Keychain exports, account files, or + unredacted logs. Security issues belong in a private vulnerability report. + - type: input + id: version + attributes: + label: CodexBar version + placeholder: e.g. 0.45.3 + validations: + required: true + - type: input + id: environment + attributes: + label: Operating system and architecture + placeholder: e.g. macOS 15.5 (arm64) or Ubuntu 24.04 (x86_64) + validations: + required: true + - type: dropdown + id: install + attributes: + label: Installation method + options: + - GitHub release + - Homebrew + - Built from source + - CLI tarball + - Other + validations: + required: true + - type: input + id: provider + attributes: + label: Provider + description: Use `None` if this is not provider-specific. + - type: dropdown + id: source_mode + attributes: + label: Source mode + options: + - Not applicable + - Automatic + - CLI + - OAuth or device flow + - Browser cookies or local storage + - API key + - Local file or database + - Manual cookie header + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + placeholder: | + 1. + 2. + 3. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Redacted evidence + description: Screenshots, exact error text, or safe diagnostics. Remove credentials and personal data first. + - type: input + id: last_working + attributes: + label: Last working version or date diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..d4337ccd62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/steipete/CodexBar/security/advisories/new + about: Report vulnerabilities privately. Do not include secrets in a public issue. + - name: Contribution guide + url: https://github.com/steipete/CodexBar/blob/main/CONTRIBUTING.md + about: Read contribution, validation, and privacy expectations before opening a pull request. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000000..8d0eee5974 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,33 @@ +name: Feature request +description: Propose a product or UX improvement before implementation. +labels: [enhancement, needs-design] +body: + - type: checkboxes + id: searched + attributes: + label: Existing work + options: + - label: I searched open issues and documentation for an existing request or supported workflow. + required: true + - type: textarea + id: problem + attributes: + label: Problem to solve + description: Describe the user problem, not only a preferred implementation. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + validations: + required: true + - type: checkboxes + id: impact + attributes: + label: Potential impact + options: + - label: This may affect provider authentication, privacy, stored data, refresh cadence, or permissions. + required: false + - label: This changes visible UI or menu behavior. + required: false diff --git a/.github/ISSUE_TEMPLATE/provider-request.yml b/.github/ISSUE_TEMPLATE/provider-request.yml new file mode 100644 index 0000000000..d61a2496dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/provider-request.yml @@ -0,0 +1,45 @@ +name: Provider request +description: Propose a provider or source-mode integration with enough evidence for review. +labels: [enhancement, "area:providers", needs-design] +body: + - type: markdown + attributes: + value: | + Do not include live credentials, cookies, browser exports, or private account data. New providers require + maintainer sign-off before implementation. + - type: input + id: provider + attributes: + label: Provider and official URL + validations: + required: true + - type: textarea + id: use_case + attributes: + label: Usage or quota data to show + description: Describe windows, credits, spend, reset times, or status data users need. + validations: + required: true + - type: textarea + id: auth + attributes: + label: Supported authentication and data source + description: CLI, documented API, OAuth, browser cookie, local file, or another source. Link official docs. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Public evidence and redacted sample + description: Include official documentation and only redacted response examples. + validations: + required: true + - type: checkboxes + id: boundary + attributes: + label: Integration boundary + options: + - label: This may require a new host API, broad file access, Keychain access, browser import, or bespoke UI. + required: false + - label: I understand that unclear operator, authorization, or privacy boundaries can block integration. + required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000000..a1519104a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,26 @@ +name: Question or support request +description: Ask for setup or usage help after checking documentation. +labels: [question, needs-triage] +body: + - type: input + id: version + attributes: + label: CodexBar version + - type: input + id: environment + attributes: + label: Operating system and architecture + - type: input + id: provider + attributes: + label: Provider and source mode, if relevant + - type: textarea + id: question + attributes: + label: Question + description: Include what you tried and the relevant documentation page. + validations: + required: true + - type: markdown + attributes: + value: Do not include API keys, cookies, Authorization headers, account files, or personal data. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..41099ec959 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 3 + groups: + github-actions: + patterns: ['*'] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..1983b82e94 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,40 @@ +## Summary + + + +## Why + + + +## Linked issue or maintainer sign-off + + + +## Validation + + + +## UI proof + + + +## Provider and privacy impact + + + +## Checklist + +- [ ] This PR is focused and contains no unrelated changes. +- [ ] I ran `make check` and the relevant focused tests. +- [ ] I ran `make test`, or explained why it was not practical. +- [ ] UI changes include visual proof; logic changes include reproducible evidence. +- [ ] I did not include credentials, cookies, Authorization headers, account files, or unredacted personal data. +- [ ] Provider data remains siloed and this change does not create an unbounded network, PTY, or UI wait. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ad938e49..239b2a8763 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + env: SWIFT_VERSION: 6.3.3 SWIFTLY_VERSION: 1.1.3 @@ -173,7 +176,7 @@ jobs: printf '| Runs lint-macos | `%s` |\n' "$RUNS_LINT_MACOS" } >> "$GITHUB_STEP_SUMMARY" - lint-build-test: + required: runs-on: ubuntu-24.04 timeout-minutes: 5 needs: @@ -181,6 +184,7 @@ jobs: - lint - swift-test-macos - build-linux-musl-cli + - build-linux-cli if: ${{ always() && !cancelled() }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -194,7 +198,8 @@ jobs: "${{ needs.swift-test-macos.result }}" \ "${{ needs.changes.outputs.macos-tests-deferred }}" \ "${{ needs.changes.outputs.linux-musl-build }}" \ - "${{ needs.build-linux-musl-cli.result }}" + "${{ needs.build-linux-musl-cli.result }}" \ + "${{ needs.build-linux-cli.result }}" - name: Summarize aggregate CI gate if: ${{ always() }} @@ -209,6 +214,7 @@ jobs: LINUX_MUSL_BUILD: ${{ needs.changes.outputs.linux-musl-build }} LINUX_MUSL_BUILD_REASON: ${{ needs.changes.outputs.linux-musl-build-reason }} LINUX_MUSL_RESULT: ${{ needs.build-linux-musl-cli.result }} + LINUX_CLI_RESULT: ${{ needs.build-linux-cli.result }} run: | set -euo pipefail reason="${MACOS_TESTS_REASON:-}" @@ -216,7 +222,7 @@ jobs: musl_reason="${LINUX_MUSL_BUILD_REASON:-}" musl_reason="${musl_reason//|/\\|}" { - printf '### Aggregate CI gate\n\n' + printf '### Required CI gate\n\n' printf '| Field | Value |\n' printf '| --- | --- |\n' printf '| lint result | `%s` |\n' "$LINT_RESULT" @@ -228,6 +234,7 @@ jobs: printf '| Linux musl build required | `%s` |\n' "${LINUX_MUSL_BUILD:-}" printf '| Linux musl gate reason | %s |\n' "$musl_reason" printf '| build-linux-musl-cli result | `%s` |\n' "$LINUX_MUSL_RESULT" + printf '| build-linux-cli result | `%s` |\n' "$LINUX_CLI_RESULT" } >> "$GITHUB_STEP_SUMMARY" build-linux-musl-cli: diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml new file mode 100644 index 0000000000..5ea7009ba4 --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,77 @@ +name: PR policy + +on: + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + pull-requests: read + +jobs: + pr-policy: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Check title and contribution template + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const pull = context.payload.pull_request + const issues = [] + if (pull.user.login === "dependabot[bot]") { + core.info("Skipping the human contribution policy for Dependabot.") + } else { + const conventionalTitle = /^(build|ci|chore|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9][a-z0-9-]*\))?:\s+\S.*$/ + const imperativeTitle = /^[A-Z][^.!?\n]{2,118}$/ + + if (!conventionalTitle.test(pull.title) && !imperativeTitle.test(pull.title)) { + issues.push("Use a concise conventional or imperative PR title, such as `fix(provider): correct reset time`.") + } + + const body = pull.body || "" + const requiredSections = [ + "Summary", + "Why", + "Linked issue or maintainer sign-off", + "Validation", + "UI proof", + "Provider and privacy impact", + "Checklist", + ] + + const sectionContent = section => { + const match = body.match(new RegExp(`(?:^|\\n)## ${section}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`)) + return match ? match[1].replace(//g, "").trim() : "" + } + + for (const section of requiredSections) { + if (!new RegExp(`^## ${section}$`, "m").test(body)) { + issues.push(`Missing PR template section: ${section}.`) + } + } + + for (const section of [ + "Summary", + "Why", + "Linked issue or maintainer sign-off", + "Validation", + "UI proof", + "Provider and privacy impact", + ]) { + if (!sectionContent(section)) { + issues.push(`Fill in the ${section} section with concrete information.`) + } + } + + const linkedIssue = sectionContent("Linked issue or maintainer sign-off") + const issueReference = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?)\s*:?\s+(?:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)?#\d+/i + const noIssueExplanation = /(?:no|without)\s+(?:linked\s+)?issue\b/i + const maintainerSignOff = /\bmaintainer\s+sign[- ]off\b/i + if (linkedIssue && !issueReference.test(linkedIssue) && !noIssueExplanation.test(linkedIssue) && !maintainerSignOff.test(linkedIssue)) { + issues.push("Use `Fixes #123`, `Refs #123`, or explain why no linked issue or maintainer sign-off applies.") + } + } + + if (issues.length > 0) { + core.setFailed(`PR policy needs attention:\n- ${issues.join("\n- ")}`) + } diff --git a/AGENTS.md b/AGENTS.md index 21b2f857cf..7ba7d1fe0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ ## Build, Test, Run - Dev loop: `./Scripts/compile_and_run.sh` kills old instances, builds, packages, relaunches `CodexBar.app`, and confirms it stays running; add `--test` for the sharded full suite. - Quick build/test: `swift build` (debug) or `swift build -c release`; `make test` for the sharded full suite. -- Package locally: `./Scripts/package_app.sh` to refresh `CodexBar.app`, then restart with `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`. +- Package locally: `./Scripts/package_app.sh` to refresh `CodexBar.app`; launch the freshly built bundle from the current repository rather than a user-specific checkout path. - Release flow: `./Scripts/release.sh`; app metadata lives in `.mac-release.env`, repo build/signing stays in `Scripts/sign-and-notarize.sh`, and validation steps live in `docs/RELEASING.md`. ## Coding Style & Naming @@ -33,13 +33,13 @@ ## Agent Notes - Use the provided scripts and package manager (SwiftPM); avoid adding dependencies or tooling without confirmation. - Menu bar automation: capture the target screen first and verify the CodexBar icon is visibly onscreen. Reject `click-extra` success when coordinates fall outside display bounds; hidden menu extras are not click proof. -- Validate UI/runtime behavior against the freshly built bundle; restart via the pkill+open command above to avoid running stale binaries. -- To guarantee the right bundle is running after a rebuild, use: `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`. +- Validate UI/runtime behavior against the freshly built bundle; restart it from the current repository to avoid running stale binaries. +- To guarantee the right bundle is running after a rebuild, stop the existing process and launch `CodexBar.app` from the current repository root. - For CLI-testable provider/parser/settings behavior, use CLI/focused tests instead of `Scripts/package_app.sh` or `./Scripts/compile_and_run.sh`. - Run `./Scripts/compile_and_run.sh` only when UI/runtime behavior needs bundle-level validation; it builds, tests, packages, relaunches, and verifies the app stays running. - Widget/Tahoe UI issues: use Parallels macOS VM plus screenshots/clicks for autonomous verification. - Release script: keep it in the foreground; do not background it—wait until it finishes. -- Sparkle release key: use `.mac-release.env` `MAC_RELEASE_SIGNING_KEY_FILE`, the legacy `AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=` key. Do not use `sparkle-private-key-KEEP-SECURE.txt`; that is VibeTunnel's mismatched key. +- Sparkle uses a public verification key in the packaged app. Release signing material is private: reference it only through `.mac-release.env` `MAC_RELEASE_SIGNING_KEY_FILE`; never copy a signing key or private-key filename into source, docs, logs, or diagnostics. - Swift concurrency: treat sibling `async let` tasks as a review red flag when one child is required and another is optional/best-effort. Prefer sequential awaits or a drained `withThrowingTaskGroup` that surfaces required failures and explicitly contains optional failures; crash stacks mentioning `swift_task_dealloc` or `asyncLet_finish_after_task_completion` should trigger an audit of nearby `async let` usage. - Prefer modern SwiftUI/Observation macros: use `@Observable` models with `@State` ownership and `@Bindable` in views; avoid `ObservableObject`, `@ObservedObject`, and `@StateObject`. - Favor modern macOS 15+ APIs over legacy/deprecated counterparts when refactoring (Observation, new display link APIs, updated menu item styling, etc.). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..fc31e5d8f6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to CodexBar + +Thanks for helping make CodexBar more accurate, private, and reliable. This guide is the contributor entry point; follow the linked docs for provider, development, and release details. + +## What to work on + +The decision boundary in [VISION.md](VISION.md) is canonical. + +Usually welcome as a focused pull request: + +- Reproducible bug fixes and parser compatibility fixes. +- Focused tests, performance improvements, and documentation corrections. +- Small UI improvements and provider updates that follow existing descriptor, strategy, settings, and test patterns. + +Start an issue and wait for maintainer sign-off before implementing: + +- New features, providers that need a new host API, or meaningful UI/UX changes. +- Changes to authentication, browser cookies, Keychain access, privacy, local storage, refresh policy, or releases. +- New dependencies, toolchain changes, broad refactors, or architectural changes. + +For a small documentation, test, or clearly bounded bug fix, a direct PR is fine. Link the relevant issue whenever there is one. If a PR completely resolves an issue, put `Fixes #123`, `Closes #123`, or `Resolves #123` in its description. That creates GitHub's development link and closes the issue on merge. `Refs #123` is only for related context and does not mark the issue as resolved. + +## Set up and develop + +- macOS app development requires macOS 14+ and Swift 6.2+. +- Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build, test, and local-launch commands. +- Read [docs/provider.md](docs/provider.md) before adding or changing a provider. +- Keep changes small and reuse existing typed helpers and descriptor-driven provider plumbing. + +Do not run live account probes, browser-cookie imports, real Keychain reads, or `codexbar usage` against a real account merely to validate a contribution. Use fixtures, parsers, test stores, and no-UI Keychain seams unless the maintainer has explicitly asked for live validation. + +## Validate your change + +Every code change must pass: + +```bash +make check +make test +``` + +Also run focused tests for the changed provider, parser, CLI, or model when possible. UI changes need a screenshot or recording from the freshly built bundle. Logic changes need enough commands, tests, or redacted output for a reviewer to confirm the behavior without access to your account. + +New provider work must include the relevant descriptor and registry wiring, focused tests, icon/docs updates, and any Linux CLI coverage that applies. See the provider authoring checklist for the complete flow. + +## Pull request expectations + +- Keep the PR to one problem and avoid unrelated formatting or refactors. +- Prefer a concise conventional title: `fix(scope): summary`, `feat(scope): summary`, `docs: summary`, `test: summary`, `refactor(scope): summary`, or `chore: summary`. Established concise imperative titles are also accepted while the repository transitions. +- Explain the problem, the change, and the validation in your own words. AI-assisted work is welcome when the author understands and takes responsibility for it; do not submit a generated wall of text in place of evidence. +- Include before/after proof for UI changes and redacted, reproducible proof for behavior changes. +- Never include API keys, cookies, Authorization headers, account files, personal identifiers, or unredacted logs. + +The PR template turns these requirements into a review checklist. Fill in every requested section; for UI proof or provider impact, write `Not applicable` or `None` when that is the concrete answer. + +## Security and support + +- Report security vulnerabilities privately using the process in [SECURITY.md](SECURITY.md), not in a public issue. +- Use [SUPPORT.md](SUPPORT.md) to choose between a bug, a feature, a provider request, and a support question. +- The issue label taxonomy is documented in [docs/ISSUE_LABELING.md](docs/ISSUE_LABELING.md). diff --git a/README.md b/README.md index 19bb92a763..2881b01cb9 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,9 @@ Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it re - **What we do not request in the background**: no Screen Recording or Accessibility permissions; user-triggered helper actions may ask macOS for Automation permission to open Terminal. No passwords are stored (browser cookies are reused when you opt in). ## Docs +- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md) +- Security: [SECURITY.md](SECURITY.md) +- Support: [SUPPORT.md](SUPPORT.md) - Providers overview: [docs/providers.md](docs/providers.md) - Provider authoring: [docs/provider.md](docs/provider.md) - Issue labeling guide: [docs/ISSUE_LABELING.md](docs/ISSUE_LABELING.md) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..7543e60a76 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Reporting a vulnerability + +Please report suspected vulnerabilities through GitHub's private [Report a Vulnerability](https://github.com/steipete/CodexBar/security/advisories/new) flow. Do not open a public issue until a maintainer has confirmed that disclosure is safe. + +Include a minimal reproduction, affected CodexBar version, macOS/Linux version, impact, and the exact capability required to reproduce. A maintainer will coordinate disclosure before publishing a fix. + +Never attach API keys, cookies, Keychain exports, `auth.json` files, browser databases, complete account identities, or unredacted request/response logs. Redact secrets before sharing diagnostics. + +## Threat model + +CodexBar can interact with provider OAuth/API credentials, browser cookies and local storage, Keychain items, local configuration/session files, provider CLIs, WebKit, widgets, and signed update artifacts. These are distinct trust boundaries. A report is especially useful when it demonstrates that CodexBar can: + +- Read, disclose, or persist credentials or private data outside its documented scope. +- Access a Keychain item, browser profile, local file, or subprocess without the expected user consent or boundary. +- Cross provider identity, plan, or usage data between accounts. +- Bypass a permission, redaction, update-signature, or localhost/export access control. +- Execute untrusted content or make network requests outside the expected provider integration. + +## Out of scope + +The following are normally out of scope unless CodexBar itself expands the impact: + +- A third-party provider outage, account policy, billing decision, or undocumented API behavior. +- Credentials deliberately supplied by the user to a provider integration. +- Attacks that require local administrator access or an already-compromised macOS/Linux account. +- Bugs in an external CLI, browser, or provider service that CodexBar does not invoke or bundle. + +Reports are evaluated on reproducible impact, not on whether AI tools assisted their preparation. The reporter is responsible for understanding the report and providing original evidence. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000000..d62e43435f --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,12 @@ +# Support + +Use the issue form that matches the problem: + +- **Bug report** — CodexBar behavior is wrong, crashes, shows inaccurate usage, or cannot complete an expected flow. +- **Provider request** — You want support for a provider, source mode, quota window, or account integration. +- **Feature request** — You have a product or UX proposal that is not a defect. +- **Question** — You need setup or usage help and the documentation does not answer it. + +Before filing, check the provider documentation in [docs/](docs/) and include your CodexBar version, operating system, provider, and source mode. For account-backed providers, redact all credentials, cookies, headers, and personal data. + +Security reports must use the private process in [SECURITY.md](SECURITY.md), never a public issue. diff --git a/Scripts/ci_verify_test_jobs.sh b/Scripts/ci_verify_test_jobs.sh index e49e57398c..c8f0fecb9c 100755 --- a/Scripts/ci_verify_test_jobs.sh +++ b/Scripts/ci_verify_test_jobs.sh @@ -9,6 +9,7 @@ macos_test_result="${4:-}" macos_tests_deferred="${5:-}" linux_musl_build_required="${6:-}" linux_musl_build_result="${7:-}" +linux_cli_result="${8:-}" if [[ "$lint_result" != "success" ]]; then printf 'lint job finished with %s\n' "${lint_result:-}" >&2 @@ -52,3 +53,10 @@ case "${linux_musl_build_required}:${linux_musl_build_result}" in exit 1 ;; esac + +if [[ "$linux_cli_result" != "success" ]]; then + printf 'Linux CLI build/test job finished with %s\n' "${linux_cli_result:-}" >&2 + exit 1 +fi + +printf 'Linux CLI build and tests passed.\n' diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 7db993e045..aef389e286 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -49,6 +49,10 @@ check_ci_path_gate() { "${ROOT_DIR}/Scripts/test_ci_path_gate.sh" } +check_pr_policy_workflow() { + "${ROOT_DIR}/Scripts/test_pr_policy_workflow.sh" +} + check_repository_size() { "${ROOT_DIR}/Scripts/check_repository_size.sh" "${ROOT_DIR}/Scripts/test_repository_size.sh" @@ -93,6 +97,7 @@ run_portable_checks() { check_sparkle_signing_paths check_swift_test_sharding check_ci_path_gate + check_pr_policy_workflow check_repository_size check_shell_scripts check_documentation_links diff --git a/Scripts/test_ci_path_gate.sh b/Scripts/test_ci_path_gate.sh index ec5423e2e8..ff384a5014 100755 --- a/Scripts/test_ci_path_gate.sh +++ b/Scripts/test_ci_path_gate.sh @@ -196,10 +196,10 @@ if [[ -s "$unterminated_output" ]]; then fi verify="${ROOT_DIR}/Scripts/ci_verify_test_jobs.sh" -"$verify" success success true success false true success >/dev/null -"$verify" success success true success false false skipped >/dev/null -"$verify" success success false skipped false true success >/dev/null -"$verify" success success false skipped false false skipped >/dev/null +"$verify" success success true success false true success success >/dev/null +"$verify" success success true success false false skipped success >/dev/null +"$verify" success success false skipped false true success success >/dev/null +"$verify" success success false skipped false false skipped success >/dev/null assert_verify_fails() { if "$verify" "$@" >/dev/null 2>&1; then @@ -208,16 +208,17 @@ assert_verify_fails() { fi } -assert_verify_fails success success true skipped false true success -assert_verify_fails success success true skipped true true success -assert_verify_fails success success false skipped true true success -assert_verify_fails success success true success true true success -assert_verify_fails success success false success false true success -assert_verify_fails success success "" skipped false true success -assert_verify_fails failure success true success false true success -assert_verify_fails success failure true success false true success -assert_verify_fails success success true success false true skipped -assert_verify_fails success success true success false false success -assert_verify_fails success success true success false "" skipped +assert_verify_fails success success true skipped false true success success +assert_verify_fails success success true skipped true true success success +assert_verify_fails success success false skipped true true success success +assert_verify_fails success success true success true true success success +assert_verify_fails success success false success false true success success +assert_verify_fails success success "" skipped false true success success +assert_verify_fails failure success true success false true success success +assert_verify_fails success failure true success false true success success +assert_verify_fails success success true success false true skipped success +assert_verify_fails success success true success false false success success +assert_verify_fails success success true success false "" skipped success +assert_verify_fails success success true success false true success skipped printf 'CI path gate tests passed.\n' diff --git a/Scripts/test_pr_policy_workflow.sh b/Scripts/test_pr_policy_workflow.sh new file mode 100755 index 0000000000..e3b4926341 --- /dev/null +++ b/Scripts/test_pr_policy_workflow.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="${ROOT_DIR}/.github/workflows/pr-policy.yml" +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +python3 - "$WORKFLOW" "$TEMP_DIR/pr-policy.js" <<'PY' +import pathlib +import sys + +workflow = pathlib.Path(sys.argv[1]).read_text().splitlines() +script_start = next(index for index, line in enumerate(workflow) if line == " script: |") + 1 +script_lines = [] +for line in workflow[script_start:]: + if line and not line.startswith(" "): + break + script_lines.append(line[12:] if line else "") + +if not script_lines: + raise SystemExit("PR policy script was not found") + +pathlib.Path(sys.argv[2]).write_text("\n".join(script_lines) + "\n") +PY + +run_policy() { + local author="$1" + local title="$2" + local body="$3" + node - "$TEMP_DIR/pr-policy.js" "$author" "$title" "$body" <<'JS' +const fs = require("fs") +const [scriptPath, author, title, body] = process.argv.slice(2) +const failures = [] +const context = { + payload: { + pull_request: { + title, + body, + user: { login: author }, + }, + }, +} +const core = { + info() {}, + setFailed(message) { failures.push(message) }, +} +eval(fs.readFileSync(scriptPath, "utf8")) +if (failures.length > 0) { + console.error(failures.join("\n")) + process.exit(1) +} +JS +} + +valid_body=$'## Summary\n\nConcrete change.\n\n## Why\n\nReason.\n\n## Linked issue or maintainer sign-off\n\nFixes #123.\n\n## Validation\n\n`make check`\n\n## UI proof\n\nNot applicable.\n\n## Provider and privacy impact\n\nNone.\n\n## Checklist\n\n- [x] Focused.' +run_policy "octocat" "chore(governance): add contributor baseline" "$valid_body" +run_policy "octocat" "Fix a tricky regression" "$valid_body" +run_policy "dependabot[bot]" "Bump actions/checkout from 6 to 7" "" + +invalid_body=${valid_body/Concrete change./} +if run_policy "octocat" "chore(governance): add contributor baseline" "$invalid_body" >/dev/null 2>&1; then + echo "PR policy accepted an empty Summary section" >&2 + exit 1 +fi + +invalid_linked=${valid_body/Fixes \#123/Related context only.} +if run_policy "octocat" "chore(governance): add contributor baseline" "$invalid_linked" >/dev/null 2>&1; then + echo "PR policy accepted an unexplained issue relationship" >&2 + exit 1 +fi + +placeholder_body=$'## Summary\n\n\n\n## Why\n\n\n\n## Linked issue or maintainer sign-off\n\n\n\n## Validation\n\n\n\n## UI proof\n\n\n\n## Provider and privacy impact\n\n\n\n## Checklist\n\n- [ ] Focused.' +if run_policy "octocat" "chore(governance): add contributor baseline" "$placeholder_body" >/dev/null 2>&1; then + echo "PR policy accepted template guidance instead of contributor content" >&2 + exit 1 +fi + +echo "PR policy workflow tests passed." diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 38a6918e8b..d9fc9a3352 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -214,7 +214,22 @@ func L(_ key: String, language: String) -> String { } func codexBarLocalizedLocale() -> Locale { - let language = resolvedAppLanguage() + codexBarLocale(forLanguage: resolvedAppLanguage()) +} + +/// Returns the locale of the resource bundle currently selected by `L`. +/// +/// This can differ from `Locale.current` when the app falls back to a supported language. Plural +/// formatting must use this locale so it follows the same language as the resolved strings. +func codexBarLocalizedResourceLocale() -> Locale { + let bundleURL = localizedBundle().bundleURL + guard bundleURL.pathExtension == "lproj" else { + return codexBarLocalizedLocale() + } + return codexBarLocale(forLanguage: bundleURL.deletingPathExtension().lastPathComponent) +} + +private func codexBarLocale(forLanguage language: String) -> Locale { guard !language.isEmpty else { return .current } let normalized = language.lowercased() if normalized == "ar" || normalized.hasPrefix("ar-") { diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 32823b6d22..bbe65498ec 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -39,19 +39,21 @@ enum UsagePaceText { static func sessionEquivalentDetail(forecast: SessionEquivalentForecast) -> SessionEquivalentDetail { let displayedEstimate = Self.boundedFullWindowCount(forecast.estimatedWindowsToExhaustWeekly) - let numberText = String.localizedStringWithFormat( - L("≈%d full 5h windows of weekly left · %d windows until reset"), - displayedEstimate, - forecast.windowsUntilReset) + let formattingLocale = codexBarLocalizedResourceLocale() + let numberText = String( + format: L("≈%d full 5h windows of weekly left · %d windows until reset"), + locale: formattingLocale, + arguments: [displayedEstimate, forecast.windowsUntilReset]) let verdictText: String if forecast.estimatedWindowsToExhaustWeekly >= forecast.availableWindowsUntilReset { verdictText = L("Weekly cannot run out before reset at this pace") } else { let windowsEarly = Self.boundedWindowCount( forecast.availableWindowsUntilReset - forecast.estimatedWindowsToExhaustWeekly) - verdictText = String.localizedStringWithFormat( - L("Weekly can run out ≈%d windows early"), - max(1, windowsEarly)) + verdictText = String( + format: L("Weekly can run out ≈%d windows early"), + locale: formattingLocale, + arguments: [max(1, windowsEarly)]) } return SessionEquivalentDetail( verdictText: verdictText, diff --git a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift index 7a7d2572c2..1d4e2f7b98 100644 --- a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift +++ b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift @@ -54,6 +54,31 @@ struct LocalizationBundleCacheTests { #expect(bundle.bundleURL.lastPathComponent == "en.lproj") } + @Test + func `format locale follows the resolved resource bundle`() { + let english = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + codexBarLocalizedResourceLocale() + } + #expect(english.language.languageCode?.identifier == "en") + + let fallback = CodexBarLocalizationOverride.$appLanguage.withValue("zz-unknown") { + codexBarLocalizedResourceLocale() + } + #expect(fallback.language.languageCode?.identifier == "en") + } + + @Test + func `resource locale expands English stringsdict singular forms`() { + let rendered = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + String( + format: L("≈%d full 5h windows of weekly left · %d windows until reset"), + locale: codexBarLocalizedResourceLocale(), + arguments: [1, 1]) + } + + #expect(rendered == "≈1 full 5h window of weekly left · 1 window until reset") + } + @Test func `resolution survives an explicit cache reset`() { let first = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 17e398ce42..de61b8736f 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct SpendDashboardControllerTests { @Test func `empty codex history loads as successful inactive source`() async { @@ -288,7 +289,7 @@ struct SpendDashboardControllerTests { let snapshot = Self.input(id: "claude", provider: .claude, cost: 3).snapshot store._setTokenSnapshotForTesting(snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) @@ -408,9 +409,11 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 3).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) controller.update(configuration: firstConfiguration) @@ -431,9 +434,11 @@ struct SpendDashboardControllerTests { #expect(controller.failedSourceCount == 1) #expect(store.tokenSnapshot(for: .claude)?.last30DaysCostUSD == 3) - let reopenedController = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let reopenedController = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) reopenedController.update(configuration: replacementConfiguration) await Self.waitUntil { !reopenedController.isRefreshing } #expect(reopenedController.model.groups.isEmpty) @@ -487,9 +492,11 @@ struct SpendDashboardControllerTests { store._setTokenSnapshotForTesting(Self.input(provider: .mistral, cost: 3).snapshot, provider: .mistral) store._test_providerRefreshOverride = { _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: selectedBackupConfiguration) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 3) @@ -527,9 +534,11 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 4).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 4) @@ -557,9 +566,11 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) controller.update(configuration: firstConfiguration) await Self.waitUntil { !controller.isRefreshing } @@ -626,9 +637,11 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 5) @@ -644,9 +657,11 @@ struct SpendDashboardControllerTests { #expect(controller.model.groups.isEmpty) #expect(controller.failedSourceCount == 1) - let reopenedController = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let reopenedController = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) reopenedController.update(configuration: reenabledConfiguration) await Self.waitUntil { !reopenedController.isRefreshing } #expect(reopenedController.model.groups.isEmpty) diff --git a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift index 88e594eca4..23902d4d79 100644 --- a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift @@ -124,9 +124,11 @@ struct SpendDashboardTokenProvenanceTests { store.activateCachedTokenAccountSnapshot(provider: .mistral, accountID: account.id) #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) store._test_providerRefreshOverride = { _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 3) @@ -148,9 +150,11 @@ struct SpendDashboardTokenProvenanceTests { return loadCount == 1 ? Self.tokenSnapshot(cost: 4) : Self.emptyTokenSnapshot() } await store.refreshTokenUsageNow(for: .bedrock, force: true) - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 4) @@ -177,9 +181,11 @@ struct SpendDashboardTokenProvenanceTests { } await store.refreshTokenUsageNow(for: .bedrock, force: true) let publicationRevision = store.tokenSnapshotPublicationRevision(for: .bedrock) - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } diff --git a/docs/ISSUE_LABELING.md b/docs/ISSUE_LABELING.md index 8e0ba964a6..7352c71777 100644 --- a/docs/ISSUE_LABELING.md +++ b/docs/ISSUE_LABELING.md @@ -72,6 +72,7 @@ Avoid using `question` as a generic fallback when the issue is actually a bug or - `area:notifications` — threshold alerts, prompt waiting, quota notifications - `area:export-integration` — Prometheus, HTTP server mode, external integrations - `area:accounts` — multiple accounts, account discovery, account switching UX +- `area:providers` — provider requests, source-mode support, and integration boundaries ## Provider labels