diff --git a/.gitattributes b/.gitattributes index 9befbbf..8f471ed 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,4 +6,7 @@ *.cs text # Declare files that will always have CRLF line endings on checkout. -*.sln text eol=crlf \ No newline at end of file +*.sln text eol=crlf + +# GitHub Agentic Workflows lock files +.github/workflows/*.lock.yml linguist-generated=true merge=ours diff --git a/.github/workflows/api-sync-check.lock.yml b/.github/workflows/api-sync-check.lock.yml new file mode 100644 index 0000000..1e33462 --- /dev/null +++ b/.github/workflows/api-sync-check.lock.yml @@ -0,0 +1,1515 @@ +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"42b773703e2b8d8b28110e2ea7b5ad06a78ad9d95f63e7bb8e85b37a12400f42","body_hash":"02f4e547ca19e7da3189e862da626398d6a8db2074d703e2fb864a1f017e23f1","compiler_version":"v0.77.5","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.77.5","version":"v0.77.5"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.58"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.22"},{"image":"ghcr.io/github/github-mcp-server:v1.1.0"},{"image":"node:lts-alpine","digest":"sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14","pinned_image":"node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14"}]} +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.77.5). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Weekly check to keep R3Ext APIs and features in sync with ReactiveUI and DynamicData. Identifies out-of-sync APIs and opens a pull request with the necessary updates. +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_CI_TRIGGER_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) +# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 +# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 +# - github/gh-aw-actions/setup@v0.77.5 +# +# Container images used: +# - ghcr.io/github/gh-aw-firewall/agent:0.25.58 +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58 +# - ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.58 +# - ghcr.io/github/gh-aw-firewall/squid:0.25.58 +# - ghcr.io/github/gh-aw-mcpg:v0.3.22 +# - ghcr.io/github/github-mcp-server:v1.1.0 +# - node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 + +name: "API Sync Check โ€” ReactiveUI & DynamicData" +on: + schedule: + - cron: "21 13 * * 1" + # Friendly format: weekly on Monday (scattered) + # skip-if-match: is:pr is:open label:api-sync in:title "[api-sync]" # Skip-if-match processed as search check in pre-activation job + workflow_dispatch: + inputs: + aw_context: + default: "" + description: "Agent caller context (used internally by Agentic Workflows)." + required: false + type: string + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "API Sync Check โ€” ReactiveUI & DynamicData" + +jobs: + activation: + needs: pre_activation + if: needs.pre_activation.outputs.activated == 'true' + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + outputs: + comment_id: "" + comment_repo: "" + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/api-sync-check.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AGENT_VERSION: "1.0.55" + GH_AW_INFO_CLI_VERSION: "v0.77.5" + GH_AW_INFO_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["dotnet"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ”„" + GH_AW_COMPILED_STRICT: "true" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + .github + .agents + .antigravity + .claude + .codex + .crush + .gemini + .opencode + .pi + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "api-sync-check.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_COMPILED_VERSION: "v0.77.5" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_84bb1231bd368268_EOF' + + GH_AW_PROMPT_84bb1231bd368268_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_84bb1231bd368268_EOF' + + Tools: create_pull_request, missing_tool, missing_data, noop + GH_AW_PROMPT_84bb1231bd368268_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" + cat << 'GH_AW_PROMPT_84bb1231bd368268_EOF' + + GH_AW_PROMPT_84bb1231bd368268_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_84bb1231bd368268_EOF' + + The following GitHub context information is available for this workflow: + {{#if github.actor}} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if github.repository}} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if github.workspace}} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} + - **issue-number**: #__GH_AW_EXPR_802A9F6A__ + {{/if}} + {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} + - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ + {{/if}} + {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} + - **pull-request-number**: #__GH_AW_EXPR_463A214A__ + {{/if}} + {{#if github.event.comment.id || github.aw.context.comment_id}} + - **comment-id**: __GH_AW_EXPR_FF1D34CE__ + {{/if}} + {{#if github.run_id}} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_84bb1231bd368268_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/cli_proxy_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_84bb1231bd368268_EOF' + + {{#runtime-import .github/workflows/api-sync-check.md}} + GH_AW_PROMPT_84bb1231bd368268_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` โ€” run `safeoutputs --help` to see available tools' + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A, + GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A, + GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A, + GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/model_multipliers.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents + /tmp/gh-aw/.github/skills + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + queue: max + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_WORKFLOW_ID_SANITIZED: apisynccheck + outputs: + agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + effective_tokens_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.effective_tokens_rate_limit_error || 'false' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} + mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/api-sync-check.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Set runtime paths + id: set-runtime-paths + run: | + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.55 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.58 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Restore agent config folders from base branch + if: steps.checkout-pr.outcome == 'success' + env: + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" + - name: Restore inline sub-agents from activation artifact + env: + GH_AW_SUB_AGENT_DIR: ".github/agents" + GH_AW_SUB_AGENT_EXT: ".agent.md" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh" + - name: Restore inline skills from activation artifact + env: + GH_AW_SKILL_DIR: ".github/skills" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.58 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58 ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.58 ghcr.io/github/gh-aw-firewall/squid:0.25.58 ghcr.io/github/gh-aw-mcpg:v0.3.22 ghcr.io/github/github-mcp-server:v1.1.0 node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 + - name: Generate Safe Outputs Config + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_5c6db378b7dc3213_EOF' + {"create_pull_request":{"allowed_files":["R3Ext/**/*.cs","R3.DynamicData/**/*.cs","R3Ext.Bindings.SourceGenerator/**/*.cs","R3Ext.Bindings.MauiTargets/**/*.cs","docs/**/*.md","README.md"],"draft":true,"labels":["api-sync","automation"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review","reviewers":["copilot"],"title_prefix":"[api-sync] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_5c6db378b7dc3213_EOF + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"[api-sync] \". Labels [\"api-sync\" \"automation\"] will be automatically added. PRs will be created as drafts. Reviewers [\"copilot\"] will be assigned." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "base": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "draft": { + "type": "boolean" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + case "${DOCKER_HOST:-}" in + unix://* ) DOCKER_SOCK_PATH="${DOCKER_HOST#unix://}" ;; + /* ) DOCKER_SOCK_PATH="$DOCKER_HOST" ;; + * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;; + esac + DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.22' + + mkdir -p /home/runner/.copilot + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_5ff1c5566be662d1_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ] + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_5ff1c5566be662d1_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Start CLI Proxy + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_SERVER_URL: ${{ github.server_url }} + CLI_PROXY_POLICY: '{"allow-only":{"repos":"all","min-integrity":"none"}}' + CLI_PROXY_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.3.22' + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/start_cli_proxy.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.58/awf-config.schema.json","network":{"allowDomains":["*.vsblob.vsassets.io","api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","api.nuget.org","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","builds.dotnet.microsoft.com","ci.dot.net","dc.services.visualstudio.com","dist.nuget.org","dot.net","dotnet.microsoft.com","dotnetcli.blob.core.windows.net","github.com","host.docker.internal","nuget.org","nuget.pkg.github.com","nugetregistryv2prod.blob.core.windows.net","oneocsp.microsoft.com","pkgs.dev.azure.com","raw.githubusercontent.com","registry.npmjs.org","telemetry.enterprise.githubcopilot.com","www.microsoft.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000,"models":{"agent":["sonnet-6x","gpt-5.4","gpt-5.3","gemini-pro","any"],"antigravity":["copilot/antigravity*","google/antigravity*","gemini/antigravity*"],"any":["copilot/*","anthropic/*","openai/*","google/*","gemini/*"],"claude":["agent"],"codex":["agent"],"coding":["copilot/gpt-5*codex*","openai/gpt-5*codex*","gpt-5-codex"],"computer-use":["copilot/*computer-use*","google/*computer-use*","gemini/*computer-use*","openai/*computer-use*"],"copilot":["agent"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini":["agent"],"gemini-3-flash":["copilot/gemini-3*flash*","google/gemini-3*flash*","gemini/gemini-3*flash*"],"gemini-3-pro":["copilot/gemini-3*pro*","google/gemini-3*pro*","gemini/gemini-3*pro*"],"gemini-3.1-flash":["copilot/gemini-3.1*flash*","google/gemini-3.1*flash*","gemini/gemini-3.1*flash*"],"gemini-3.1-pro":["copilot/gemini-3.1*pro*","google/gemini-3.1*pro*","gemini/gemini-3.1*pro*"],"gemini-3.5-flash":["copilot/gemini-3.5*flash*","google/gemini-3.5*flash*","gemini/gemini-3.5*flash*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"gpt-5.2":["copilot/gpt-5.2*","openai/gpt-5.2*"],"gpt-5.3":["copilot/gpt-5.3*","openai/gpt-5.3*"],"gpt-5.4":["copilot/gpt-5.4*","openai/gpt-5.4*"],"gpt-5.5":["copilot/gpt-5.5*","openai/gpt-5.5*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"opus":["copilot/*opus*","anthropic/*opus*"],"opusplan":["opus?effort=high"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"robotics":["copilot/*robotics*","google/*robotics*","gemini/*robotics*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"],"sonnet-6x":["copilot/*sonnet-4-5-*","anthropic/*sonnet-4-5-*","copilot/*sonnet-4-6*","anthropic/*sonnet-4-6*"],"summarization":["haiku","gpt-5-mini","gemini-flash-lite","mini"],"vision":["copilot/gemini-*image*","gemini/gemini-*image*","copilot/gemini-*flash*","gemini/gemini-*flash*"]}},"container":{"imageTag":"0.25.58"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" + GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + elif [ -d "/home/runner/work/_tool" ]; then + GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro" + fi + # shellcheck disable=SC1003 + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GH_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull --difc-proxy-host host.docker.internal:18443 --difc-proxy-ca-cert /tmp/gh-aw/difc-proxy-tls/ca.crt \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner โ€” check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.77.5 + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || github.token }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + XDG_CONFIG_HOME: /home/runner + - name: Stop CLI Proxy + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/stop_cli_proxy.sh" + - name: Detect agent errors + if: always() + id: detect-agent-errors + continue-on-error: true + run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.vsblob.vsassets.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.nuget.org,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,builds.dotnet.microsoft.com,ci.dot.net,dc.services.visualstudio.com,dist.nuget.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,github.com,host.docker.internal,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,oneocsp.microsoft.com,pkgs.dev.azure.com,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com,www.microsoft.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs/audit dirs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + concurrency: + group: "gh-aw-conclusion-api-sync-check" + cancel-in-progress: false + queue: max + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/api-sync-check.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/api-sync-check.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Log detection run + id: detection_runs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/api-sync-check.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} + GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_detection_runs.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/api-sync-check.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/api-sync-check.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/api-sync-check.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "api-sync-check" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" + GH_AW_ENGINE_ID: "copilot" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} + GH_AW_EFFECTIVE_TOKENS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.effective_tokens_rate_limit_error || 'false' }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" + GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} + GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + GH_AW_MAX_EFFECTIVE_TOKENS: "25000000" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + detection: + needs: + - activation + - agent + if: > + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_reason: ${{ steps.detection_conclusion.outputs.reason }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/api-sync-check.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Clean stale firewall files from agent artifact + run: | + rm -rf /tmp/gh-aw/sandbox/firewall/logs + rm -rf /tmp/gh-aw/sandbox/firewall/audit + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.58 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58 ghcr.io/github/gh-aw-firewall/squid:0.25.58 + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP Config for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json" + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then + echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." + fi + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + WORKFLOW_DESCRIPTION: "Weekly check to keep R3Ext APIs and features in sync with ReactiveUI and DynamicData. Identifies out-of-sync APIs and opens a pull request with the necessary updates." + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.55 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.58 + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.58/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","registry.npmjs.org","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000},"container":{"imageTag":"0.25.58"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" + GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + elif [ -d "/home/runner/work/_tool" ]; then + GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro" + fi + # shellcheck disable=SC1003 + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + -- /bin/bash -c 'set +o histexpand; GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner โ€” check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: v0.77.5 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + XDG_CONFIG_HOME: /home/runner + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: detection + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Parse and conclude threat detection + id: detection_conclusion + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + DETECTION_AGENTIC_EXECUTION_OUTCOME: ${{ steps.detection_agentic_execution.outcome }} + GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" + with: + script: | + try { + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + } catch (loadErr) { + const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false'; + const detectionExecutionFailed = process.env.DETECTION_AGENTIC_EXECUTION_OUTCOME === 'failure'; + const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr)); + core.error(msg); + core.setOutput('reason', 'parse_error'); + if (continueOnError && !detectionExecutionFailed) { + core.warning('\u26A0\uFE0F ' + msg); + core.setOutput('conclusion', 'warning'); + core.setOutput('success', 'false'); + } else { + core.setOutput('conclusion', 'failure'); + core.setOutput('success', 'false'); + core.setFailed(msg); + } + } + + pre_activation: + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_skip_if_match.outputs.skip_check_ok == 'true' }} + matched_command: '' + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/api-sync-check.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_REQUIRED_ROLES: "admin,maintainer,write" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs'); + await main(); + - name: Check skip-if-match query + id: check_skip_if_match + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SKIP_QUERY: "is:pr is:open label:api-sync in:title \"[api-sync]\"" + GH_AW_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_SKIP_MAX_MATCHES: "1" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_skip_if_match.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/api-sync-check" + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} + GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.55" + GH_AW_WORKFLOW_EMOJI: "๐Ÿ”„" + GH_AW_WORKFLOW_ID: "api-sync-check" + GH_AW_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/api-sync-check.md" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }} + created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "API Sync Check โ€” ReactiveUI & DynamicData" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/api-sync-check.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Extract base branch from agent output + id: extract-base-branch + if: steps.download-agent-output.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/extract_base_branch_from_agent_output.cjs'); + await main(); + - name: Checkout repository (trusted default branch for comment events) + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Checkout repository + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} + token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Configure Git credentials + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_ALLOWED_DOMAINS: "*.vsblob.vsassets.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.nuget.org,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,builds.dotnet.microsoft.com,ci.dot.net,dc.services.visualstudio.com,dist.nuget.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,github.com,host.docker.internal,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,oneocsp.microsoft.com,pkgs.dev.azure.com,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com,www.microsoft.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"allowed_files\":[\"R3Ext/**/*.cs\",\"R3.DynamicData/**/*.cs\",\"R3Ext.Bindings.SourceGenerator/**/*.cs\",\"R3Ext.Bindings.MauiTargets/**/*.cs\",\"docs/**/*.md\",\"README.md\"],\"draft\":true,\"labels\":[\"api-sync\",\"automation\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\",\"reviewers\":[\"copilot\"],\"title_prefix\":\"[api-sync] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + if-no-files-found: ignore + diff --git a/.github/workflows/api-sync-check.md b/.github/workflows/api-sync-check.md new file mode 100644 index 0000000..cd7bf2e --- /dev/null +++ b/.github/workflows/api-sync-check.md @@ -0,0 +1,121 @@ +--- +emoji: ๐Ÿ”„ +description: Weekly check to keep R3Ext APIs and features in sync with ReactiveUI and DynamicData. Identifies out-of-sync APIs and opens a pull request with the necessary updates. +on: + schedule: weekly on Monday + skip-if-match: 'is:pr is:open label:api-sync in:title "[api-sync]"' +permissions: + contents: read + issues: read + pull-requests: read +tools: + github: + mode: gh-proxy + toolsets: [default] +network: + allowed: [dotnet] +safe-outputs: + create-pull-request: + title-prefix: "[api-sync] " + labels: [api-sync, automation] + draft: true + reviewers: [copilot] + allowed-files: + - "R3Ext/**/*.cs" + - "R3.DynamicData/**/*.cs" + - "R3Ext.Bindings.SourceGenerator/**/*.cs" + - "R3Ext.Bindings.MauiTargets/**/*.cs" + - "docs/**/*.md" + - "README.md" +--- + +# API Sync Check โ€” ReactiveUI & DynamicData + +## Goal + +Keep R3Ext aligned with the latest ReactiveUI and DynamicData APIs and features. Each week: + +1. Inspect recent commits and releases in both upstream repos. +2. Compare against the current R3Ext implementation. +3. If gaps exist, produce a pull request with the necessary source updates. +4. If everything is already in sync, call `noop`. + +## Context + +- **R3Ext repo (this repo)** โ€” a reactive MVVM + dynamic-data library built on R3. + - `R3Ext/` โ€” ReactiveUI-compatible APIs: commands, interactions, bindings, MVVM helpers. + - `R3.DynamicData/` โ€” DynamicData port: observable caches, lists, operators, change sets. +- **ReactiveUI upstream**: `https://github.com/reactiveui/reactiveui` +- **DynamicData upstream**: `https://github.com/reactivemarbles/DynamicData` + +## Steps + +### 1 โ€” Gather upstream changes + +Use `gh` to inspect each upstream repository. + +For **ReactiveUI** (`reactiveui/reactiveui`): +- Fetch commits from the last 7 days on the default branch. +- Fetch the latest release tag and release notes. +- List recently modified public C# files to spot new or changed public APIs (classes, interfaces, public methods/properties). + +For **DynamicData** (`reactivemarbles/DynamicData`): +- Fetch commits from the last 7 days on the default branch. +- Fetch the latest release tag and release notes. +- List recently modified public C# files to spot new or changed public APIs. + +### 2 โ€” Inspect the current R3Ext implementation + +Read relevant files in this repo to understand the current state: +- `R3Ext/Commands/` โ€” RxCommand implementations. +- `R3Ext/Interactions/` โ€” RxInteraction implementations. +- `R3Ext/Bindings/` โ€” binding helpers and source-generator hooks. +- `R3Ext/Extensions/` โ€” observable extension methods. +- `R3.DynamicData/` โ€” DynamicData port. + +### 3 โ€” Identify gaps + +Compare the upstream public API surface against what R3Ext implements: + +- **Missing APIs**: public types or members in the upstream that have no R3Ext equivalent. +- **Signature drift**: existing R3Ext methods whose signatures no longer match the upstream (parameter types, return types, overloads). +- **New features**: functionality added upstream (new operators, new command overloads, new change-set types) not yet present in R3Ext. +- **Removed/deprecated upstream APIs**: flag any that should also be deprecated in R3Ext. + +Ignore internal, private, or test-only members. Ignore platform-specific code that does not apply to R3Ext's .NET / MAUI targets. + +### 4 โ€” Apply updates + +For each identified gap: +- Add or update the corresponding C# source file(s) to bring R3Ext in line with the upstream API. +- Follow the existing code style: XML doc comments for public members, no reflection, AOT-safe patterns. +- Prefer additive changes (new overloads, new types) over breaking changes. If a breaking change is required, mark it with `// TODO: breaking change โ€” ` and note it in the PR body. +- Do not modify test files or benchmark files. + +### 5 โ€” Produce output + +**If updates were made**: create a pull request using the `create-pull-request` safe output. + +The PR body must include: + +``` +## Summary + +Brief description of what changed and why. + +## ReactiveUI changes +- List specific upstream commits or release notes that motivated each change. + +## DynamicData changes +- List specific upstream commits or release notes that motivated each change. + +## Breaking changes (if any) +- Describe any breaking changes or API removals. +``` + +**If nothing is out of sync**: call `noop` with a short message such as "R3Ext APIs are already in sync with ReactiveUI and DynamicData โ€” no changes needed." + +## Safe Outputs + +- Use `create-pull-request` to open a draft PR when source changes are produced. +- Use `noop` when R3Ext is already in sync and no code changes are needed. diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 6c5ef2b..4892429 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -58,64 +58,45 @@ jobs: - name: Restore dependencies (libraries and tests only) run: | - # Avoid restoring the MAUI SampleApp on Linux runners (iOS/macCatalyst workloads not available) - dotnet restore R3Ext/R3Ext.csproj - dotnet restore R3Ext.Bindings.SourceGenerator/R3Ext.Bindings.SourceGenerator.csproj - dotnet restore R3Ext.Bindings.MauiTargets/R3Ext.Bindings.MauiTargets.csproj - dotnet restore R3.DynamicData/R3.DynamicData.csproj + # Auto-discover packable library projects; excludes tests, samples, and benchmarks + # (those set false or omit the property on executable/test targets). + # New shippable libraries are picked up automatically once they set true. + PACKABLE=$(find . -name "*.csproj" -not -path "*/obj/*" | xargs grep -l 'true' | sort) + for proj in $PACKABLE; do + dotnet restore "$proj" + done + # Test projects are not packable but must be restored to run tests dotnet restore R3Ext.Tests/R3Ext.Tests.csproj - dotnet restore R3.DynamicData.Tests/R3.DynamicData.Tests.csproj + dotnet restore R3Ext.DynamicData.Tests/R3Ext.DynamicData.Tests.csproj - name: Build Libraries (exclude samples) run: | export DOTNET_USE_SHARED_COMPILATION=false - # Build source generator first (dependency for main library) - dotnet build R3Ext.Bindings.SourceGenerator/R3Ext.Bindings.SourceGenerator.csproj --configuration Release --no-restore \ - /p:Version=${{ steps.gitversion.outputs.nuGetVersionV2 }} \ - /p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} \ - /p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} \ - /p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} - - # Build main library (uses source generator) - dotnet build R3Ext/R3Ext.csproj --configuration Release --no-restore \ - /p:Version=${{ steps.gitversion.outputs.nuGetVersionV2 }} \ - /p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} \ - /p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} \ - /p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} - - # Build MAUI targets - dotnet build R3Ext.Bindings.MauiTargets/R3Ext.Bindings.MauiTargets.csproj --configuration Release --no-restore \ - /p:Version=${{ steps.gitversion.outputs.nuGetVersionV2 }} \ - /p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} \ - /p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} \ - /p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} - - # Build DynamicData library - dotnet build R3.DynamicData/R3.DynamicData.csproj --configuration Release --no-restore \ - /p:Version=${{ steps.gitversion.outputs.nuGetVersionV2 }} \ - /p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} \ - /p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} \ - /p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} + # Alphabetical sort places R3Ext.Bindings.SourceGenerator before R3Ext (dot < slash), + # so the source generator is always built before the main library that references it. + # dotnet build also resolves project references automatically, so ordering is belt-and-suspenders. + PACKABLE=$(find . -name "*.csproj" -not -path "*/obj/*" | xargs grep -l 'true' | sort) + for proj in $PACKABLE; do + dotnet build "$proj" --configuration Release --no-restore \ + /p:Version=${{ steps.gitversion.outputs.nuGetVersionV2 }} \ + /p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} \ + /p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} \ + /p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} + done - name: Test run: | export DOTNET_USE_SHARED_COMPILATION=false dotnet test R3Ext.Tests/R3Ext.Tests.csproj --configuration Release --verbosity normal - dotnet test R3.DynamicData.Tests/R3.DynamicData.Tests.csproj --configuration Release --verbosity normal + dotnet test R3Ext.DynamicData.Tests/R3Ext.DynamicData.Tests.csproj --configuration Release --verbosity normal - name: Pack NuGet packages run: | - dotnet pack R3Ext/R3Ext.csproj --configuration Release --no-build --output ./artifacts \ - /p:PackageVersion=${{ steps.gitversion.outputs.nuGetVersionV2 }} - - dotnet pack R3Ext.Bindings.SourceGenerator/R3Ext.Bindings.SourceGenerator.csproj --configuration Release --no-build --output ./artifacts \ - /p:PackageVersion=${{ steps.gitversion.outputs.nuGetVersionV2 }} - - dotnet pack R3Ext.Bindings.MauiTargets/R3Ext.Bindings.MauiTargets.csproj --configuration Release --no-build --output ./artifacts \ - /p:PackageVersion=${{ steps.gitversion.outputs.nuGetVersionV2 }} - - dotnet pack R3.DynamicData/R3.DynamicData.csproj --configuration Release --no-build --output ./artifacts \ - /p:PackageVersion=${{ steps.gitversion.outputs.nuGetVersionV2 }} + PACKABLE=$(find . -name "*.csproj" -not -path "*/obj/*" | xargs grep -l 'true' | sort) + for proj in $PACKABLE; do + dotnet pack "$proj" --configuration Release --no-build --output ./artifacts \ + /p:PackageVersion=${{ steps.gitversion.outputs.nuGetVersionV2 }} + done - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/doc-sync.lock.yml b/.github/workflows/doc-sync.lock.yml new file mode 100644 index 0000000..dcee11d --- /dev/null +++ b/.github/workflows/doc-sync.lock.yml @@ -0,0 +1,1461 @@ +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"2ee8a9b114bcd379ff3ae0dfd2fb3e66f20dcc9d223414ff40d97cb1838b1f19","body_hash":"6bf42df01c3552240e785a7e5a0222fe6322e03f2d14872681316157a75d3af4","compiler_version":"v0.77.5","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.77.5","version":"v0.77.5"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.58"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.22"},{"image":"ghcr.io/github/github-mcp-server:v1.1.0"},{"image":"node:lts-alpine","digest":"sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14","pinned_image":"node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14"}]} +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.77.5). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Daily documentation sync โ€” identifies doc files out of sync with recent code changes and opens a pull request with the necessary updates. +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_CI_TRIGGER_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) +# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 +# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 +# - github/gh-aw-actions/setup@v0.77.5 +# +# Container images used: +# - ghcr.io/github/gh-aw-firewall/agent:0.25.58 +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58 +# - ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.58 +# - ghcr.io/github/gh-aw-firewall/squid:0.25.58 +# - ghcr.io/github/gh-aw-mcpg:v0.3.22 +# - ghcr.io/github/github-mcp-server:v1.1.0 +# - node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 + +name: "Documentation Sync" +on: + schedule: + - cron: "44 8 * * *" + # Friendly format: daily around 8am UTC (scattered) + workflow_dispatch: + inputs: + aw_context: + default: "" + description: "Agent caller context (used internally by Agentic Workflows)." + required: false + type: string + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "Documentation Sync" + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + outputs: + comment_id: "" + comment_repo: "" + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Documentation Sync" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/doc-sync.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AGENT_VERSION: "1.0.55" + GH_AW_INFO_CLI_VERSION: "v0.77.5" + GH_AW_INFO_WORKFLOW_NAME: "Documentation Sync" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_FRONTMATTER_EMOJI: "๐Ÿ“š" + GH_AW_COMPILED_STRICT: "true" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + .github + .agents + .antigravity + .claude + .codex + .crush + .gemini + .opencode + .pi + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "doc-sync.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_COMPILED_VERSION: "v0.77.5" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_8416bd4b1991a649_EOF' + + GH_AW_PROMPT_8416bd4b1991a649_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_8416bd4b1991a649_EOF' + + Tools: create_pull_request, missing_tool, missing_data, noop + GH_AW_PROMPT_8416bd4b1991a649_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" + cat << 'GH_AW_PROMPT_8416bd4b1991a649_EOF' + + GH_AW_PROMPT_8416bd4b1991a649_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_8416bd4b1991a649_EOF' + + The following GitHub context information is available for this workflow: + {{#if github.actor}} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if github.repository}} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if github.workspace}} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} + - **issue-number**: #__GH_AW_EXPR_802A9F6A__ + {{/if}} + {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} + - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ + {{/if}} + {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} + - **pull-request-number**: #__GH_AW_EXPR_463A214A__ + {{/if}} + {{#if github.event.comment.id || github.aw.context.comment_id}} + - **comment-id**: __GH_AW_EXPR_FF1D34CE__ + {{/if}} + {{#if github.run_id}} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_8416bd4b1991a649_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/cli_proxy_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_8416bd4b1991a649_EOF' + + {{#runtime-import .github/workflows/doc-sync.md}} + GH_AW_PROMPT_8416bd4b1991a649_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` โ€” run `safeoutputs --help` to see available tools' + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A, + GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A, + GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A, + GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/model_multipliers.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents + /tmp/gh-aw/.github/skills + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + queue: max + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_WORKFLOW_ID_SANITIZED: docsync + outputs: + agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + effective_tokens_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.effective_tokens_rate_limit_error || 'false' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} + mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Documentation Sync" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/doc-sync.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Set runtime paths + id: set-runtime-paths + run: | + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.55 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.58 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Restore agent config folders from base branch + if: steps.checkout-pr.outcome == 'success' + env: + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" + - name: Restore inline sub-agents from activation artifact + env: + GH_AW_SUB_AGENT_DIR: ".github/agents" + GH_AW_SUB_AGENT_EXT: ".agent.md" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh" + - name: Restore inline skills from activation artifact + env: + GH_AW_SKILL_DIR: ".github/skills" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.58 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58 ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.58 ghcr.io/github/gh-aw-firewall/squid:0.25.58 ghcr.io/github/gh-aw-mcpg:v0.3.22 ghcr.io/github/github-mcp-server:v1.1.0 node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 + - name: Generate Safe Outputs Config + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_a4c60688f3da9733_EOF' + {"create_pull_request":{"allowed_files":["*.md","docs/**/*.md"],"branch_prefix":"doc-sync/","draft":true,"if_no_changes":"ignore","labels":["documentation"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review","title_prefix":"[doc-sync] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_a4c60688f3da9733_EOF + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Branch name will be prefixed with \"doc-sync/\". Title will be prefixed with \"[doc-sync] \". Labels [\"documentation\"] will be automatically added. PRs will be created as drafts." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "base": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "draft": { + "type": "boolean" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + case "${DOCKER_HOST:-}" in + unix://* ) DOCKER_SOCK_PATH="${DOCKER_HOST#unix://}" ;; + /* ) DOCKER_SOCK_PATH="$DOCKER_HOST" ;; + * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;; + esac + DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.22' + + mkdir -p /home/runner/.copilot + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_d63d35411c644936_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ] + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_d63d35411c644936_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Start CLI Proxy + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_SERVER_URL: ${{ github.server_url }} + CLI_PROXY_POLICY: '{"allow-only":{"repos":"all","min-integrity":"none"}}' + CLI_PROXY_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.3.22' + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/start_cli_proxy.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.58/awf-config.schema.json","network":{"allowDomains":["*.githubusercontent.com","api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","codeload.github.com","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","docs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","github.blog","github.com","github.githubassets.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.com","lfs.github.com","objects.githubusercontent.com","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","patch-diff.githubusercontent.com","ppa.launchpad.net","raw.githubusercontent.com","registry.npmjs.org","s.symcb.com","s.symcd.com","security.ubuntu.com","telemetry.enterprise.githubcopilot.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000,"models":{"agent":["sonnet-6x","gpt-5.4","gpt-5.3","gemini-pro","any"],"antigravity":["copilot/antigravity*","google/antigravity*","gemini/antigravity*"],"any":["copilot/*","anthropic/*","openai/*","google/*","gemini/*"],"claude":["agent"],"codex":["agent"],"coding":["copilot/gpt-5*codex*","openai/gpt-5*codex*","gpt-5-codex"],"computer-use":["copilot/*computer-use*","google/*computer-use*","gemini/*computer-use*","openai/*computer-use*"],"copilot":["agent"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini":["agent"],"gemini-3-flash":["copilot/gemini-3*flash*","google/gemini-3*flash*","gemini/gemini-3*flash*"],"gemini-3-pro":["copilot/gemini-3*pro*","google/gemini-3*pro*","gemini/gemini-3*pro*"],"gemini-3.1-flash":["copilot/gemini-3.1*flash*","google/gemini-3.1*flash*","gemini/gemini-3.1*flash*"],"gemini-3.1-pro":["copilot/gemini-3.1*pro*","google/gemini-3.1*pro*","gemini/gemini-3.1*pro*"],"gemini-3.5-flash":["copilot/gemini-3.5*flash*","google/gemini-3.5*flash*","gemini/gemini-3.5*flash*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"gpt-5.2":["copilot/gpt-5.2*","openai/gpt-5.2*"],"gpt-5.3":["copilot/gpt-5.3*","openai/gpt-5.3*"],"gpt-5.4":["copilot/gpt-5.4*","openai/gpt-5.4*"],"gpt-5.5":["copilot/gpt-5.5*","openai/gpt-5.5*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"opus":["copilot/*opus*","anthropic/*opus*"],"opusplan":["opus?effort=high"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"robotics":["copilot/*robotics*","google/*robotics*","gemini/*robotics*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"],"sonnet-6x":["copilot/*sonnet-4-5-*","anthropic/*sonnet-4-5-*","copilot/*sonnet-4-6*","anthropic/*sonnet-4-6*"],"summarization":["haiku","gpt-5-mini","gemini-flash-lite","mini"],"vision":["copilot/gemini-*image*","gemini/gemini-*image*","copilot/gemini-*flash*","gemini/gemini-*flash*"]}},"container":{"imageTag":"0.25.58"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" + GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + elif [ -d "/home/runner/work/_tool" ]; then + GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro" + fi + # shellcheck disable=SC1003 + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GH_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull --difc-proxy-host host.docker.internal:18443 --difc-proxy-ca-cert /tmp/gh-aw/difc-proxy-tls/ca.crt \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner โ€” check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.77.5 + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || github.token }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + XDG_CONFIG_HOME: /home/runner + - name: Stop CLI Proxy + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/stop_cli_proxy.sh" + - name: Detect agent errors + if: always() + id: detect-agent-errors + continue-on-error: true + run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs/audit dirs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + concurrency: + group: "gh-aw-conclusion-doc-sync" + cancel-in-progress: false + queue: max + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Documentation Sync" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/doc-sync.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Documentation Sync" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/doc-sync.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Log detection run + id: detection_runs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Documentation Sync" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/doc-sync.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} + GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_detection_runs.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Documentation Sync" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/doc-sync.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Documentation Sync" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/doc-sync.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Documentation Sync" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/doc-sync.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "doc-sync" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" + GH_AW_ENGINE_ID: "copilot" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} + GH_AW_EFFECTIVE_TOKENS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.effective_tokens_rate_limit_error || 'false' }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" + GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} + GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + GH_AW_MAX_EFFECTIVE_TOKENS: "25000000" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + detection: + needs: + - activation + - agent + if: > + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_reason: ${{ steps.detection_conclusion.outputs.reason }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Documentation Sync" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/doc-sync.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Clean stale firewall files from agent artifact + run: | + rm -rf /tmp/gh-aw/sandbox/firewall/logs + rm -rf /tmp/gh-aw/sandbox/firewall/audit + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.58 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58 ghcr.io/github/gh-aw-firewall/squid:0.25.58 + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP Config for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json" + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then + echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." + fi + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKFLOW_NAME: "Documentation Sync" + WORKFLOW_DESCRIPTION: "Daily documentation sync โ€” identifies doc files out of sync with recent code changes and opens a pull request with the necessary updates." + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.55 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.58 + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.58/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","registry.npmjs.org","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000},"container":{"imageTag":"0.25.58"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" + GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + elif [ -d "/home/runner/work/_tool" ]; then + GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro" + fi + # shellcheck disable=SC1003 + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + -- /bin/bash -c 'set +o histexpand; GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner โ€” check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: v0.77.5 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + XDG_CONFIG_HOME: /home/runner + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: detection + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Parse and conclude threat detection + id: detection_conclusion + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + DETECTION_AGENTIC_EXECUTION_OUTCOME: ${{ steps.detection_agentic_execution.outcome }} + GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" + with: + script: | + try { + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + } catch (loadErr) { + const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false'; + const detectionExecutionFailed = process.env.DETECTION_AGENTIC_EXECUTION_OUTCOME === 'failure'; + const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr)); + core.error(msg); + core.setOutput('reason', 'parse_error'); + if (continueOnError && !detectionExecutionFailed) { + core.warning('\u26A0\uFE0F ' + msg); + core.setOutput('conclusion', 'warning'); + core.setOutput('success', 'false'); + } else { + core.setOutput('conclusion', 'failure'); + core.setOutput('success', 'false'); + core.setFailed(msg); + } + } + + safe_outputs: + needs: + - activation + - agent + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/doc-sync" + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} + GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.55" + GH_AW_WORKFLOW_EMOJI: "๐Ÿ“š" + GH_AW_WORKFLOW_ID: "doc-sync" + GH_AW_WORKFLOW_NAME: "Documentation Sync" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/doc-sync.md" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }} + created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@v0.77.5 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Documentation Sync" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/doc-sync.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.55" + GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Extract base branch from agent output + id: extract-base-branch + if: steps.download-agent-output.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/extract_base_branch_from_agent_output.cjs'); + await main(); + - name: Checkout repository (trusted default branch for comment events) + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Checkout repository + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} + token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Configure Git credentials + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"allowed_files\":[\"*.md\",\"docs/**/*.md\"],\"branch_prefix\":\"doc-sync/\",\"draft\":true,\"if_no_changes\":\"ignore\",\"labels\":[\"documentation\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\",\"title_prefix\":\"[doc-sync] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + if-no-files-found: ignore + diff --git a/.github/workflows/doc-sync.md b/.github/workflows/doc-sync.md new file mode 100644 index 0000000..449b2ac --- /dev/null +++ b/.github/workflows/doc-sync.md @@ -0,0 +1,97 @@ +--- +emoji: ๐Ÿ“š +description: Daily documentation sync โ€” identifies doc files out of sync with recent code changes and opens a pull request with the necessary updates. +on: + schedule: daily around 8am UTC +permissions: + contents: read + issues: read + pull-requests: read +strict: true +network: + allowed: [defaults, github] +tools: + github: + mode: gh-proxy + toolsets: [default] +safe-outputs: + create-pull-request: + title-prefix: "[doc-sync] " + branch-prefix: doc-sync/ + labels: [documentation] + draft: true + if-no-changes: ignore + allowed-files: + - "*.md" + - "docs/**/*.md" +--- + +# Documentation Sync + +## Goal + +Keep the repository documentation accurate and up to date by identifying markdown files that are out of sync with recent code changes, then opening a pull request with the necessary corrections. + +## Steps + +### 1. Discover recent code changes + +Use `gh` to list commits from the past 7 days on the default branch: + +``` +gh api repos/{owner}/{repo}/commits?since=<7-days-ago>&per_page=100 +``` + +For each commit, retrieve the list of files changed. Focus only on non-documentation source files (`.cs`, `.csproj`, `.sln`, `.props`, `.targets`, `.json`). Collect a deduplicated list of changed source paths. + +### 2. Identify documentation files + +The documentation files in this repository are: + +- `README.md` โ€” primary project overview, features, installation, quick start, API reference +- `DEPLOYMENT.md` โ€” deployment and release process +- `AOT_REFLECTION_ISSUES.md` โ€” known AOT/trimming issues and workarounds +- `docs/ClosureEliminationPlan.md` โ€” plan and progress for eliminating closures in R3.DynamicData +- `docs/ClosureEliminationStatus.md` โ€” current status of the closure elimination effort +- `docs/LibraryParity.md` โ€” parity tracking between R3Ext and upstream libraries +- `docs/MigrationMatrix.md` โ€” migration guidance matrix +- `docs/UpstreamChangesReview.md` โ€” review of changes in upstream dependencies + +Read each documentation file using the `edit` or `bash` tool to understand its current content. + +### 3. Correlate code changes to documentation + +For each changed source file, determine which documentation files describe, reference, or track its functionality. Use the following heuristics: + +- Changes in `R3.DynamicData/` โ†’ correlate with `docs/ClosureEliminationPlan.md`, `docs/ClosureEliminationStatus.md`, `docs/LibraryParity.md` +- Changes in `R3Ext/`, `R3Ext.Bindings.SourceGenerator/`, `R3Ext.Bindings.MauiTargets/` โ†’ correlate with `README.md`, `docs/LibraryParity.md` +- Changes to `.csproj`, `Directory.Build.props`, `GitVersion.yml` โ†’ correlate with `DEPLOYMENT.md`, `README.md` +- New public APIs (classes, methods) โ†’ may need documentation in `README.md` +- AOT-related changes โ†’ correlate with `AOT_REFLECTION_ISSUES.md` + +### 4. Identify out-of-sync sections + +For each correlated documentation file, read the relevant source code and compare it against what the documentation describes. Identify specific sections that are inaccurate, incomplete, or missing based on the actual code. Examples: + +- A status document references a percentage or count that no longer matches reality +- A doc lists operators or APIs that have been renamed, added, or removed +- A migration or parity matrix is missing recently added or removed entries +- A plan document has completed items still marked as pending + +### 5. Apply documentation updates + +For each out-of-sync section, use the `edit` tool to apply precise, targeted corrections. Keep changes minimal and focused โ€” do not rewrite sections that remain accurate. Preserve the existing structure, tone, and formatting of each document. + +### 6. Produce the pull request + +After applying all updates, emit a `create_pull_request` output describing: + +- Which documents were changed and why +- A concise summary of each correction keyed to the source commits that triggered it + +If no documentation is out of sync after review, call `noop` with a brief explanation (e.g. "No documentation updates required for changes in the past 7 days."). + +## Safe Outputs + +- Use `create-pull-request` to open a draft PR with the documentation updates. +- Use `noop` when no changes are needed. diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 99bb8d5..0c87826 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -17,7 +17,7 @@ "&&", "dotnet", "test", - "R3.DynamicData.Tests/R3.DynamicData.Tests.csproj", + "R3Ext.DynamicData.Tests/R3Ext.DynamicData.Tests.csproj", "--nologo", "--blame-hang", "--blame-hang-timeout", diff --git a/AOT_REFLECTION_ISSUES.md b/AOT_REFLECTION_ISSUES.md index e4d7c13..911925c 100644 --- a/AOT_REFLECTION_ISSUES.md +++ b/AOT_REFLECTION_ISSUES.md @@ -1,6 +1,6 @@ # AOT and Reflection Issues Matrix -This document tracks all potential AOT (Ahead-of-Time) compilation, reflection, and expression evaluation issues found in R3Ext and R3.DynamicData projects. +This document tracks all potential AOT (Ahead-of-Time) compilation, reflection, and expression evaluation issues found in R3Ext and R3Ext.DynamicData projects. ## Status Legend - โณ **Pending** - Not yet addressed @@ -14,7 +14,7 @@ This document tracks all potential AOT (Ahead-of-Time) compilation, reflection, ## ๐ŸŽ‰ Summary: All Critical AOT Issues Resolved **Status:** โœ… **Complete** -**Test Coverage:** 459/459 tests passing (174 R3Ext + 285 R3.DynamicData) +**Test Coverage:** 459/459 tests passing (174 R3Ext + 285 R3Ext.DynamicData) **Branch:** feature/aot-reflection-fixes (5 commits) All critical Expression.Compile() and reflection-based issues have been successfully eliminated. The codebase is now fully compatible with native AOT compilation. @@ -29,12 +29,12 @@ All critical Expression.Compile() and reflection-based issues have been successf **AOT Impact:** Expression.Compile() creates dynamic methods that are incompatible with AOT #### Locations: -1. **`R3.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs`** +1. **`R3Ext.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs`** - ~~Line 31: `var getter = propertyAccessor.Compile();`~~ - ~~Line 131: `var getter = propertyAccessor.Compile();`~~ - **Context:** Used in WhenValueChanged operators to create property getters from expressions -2. **`R3.DynamicData/Binding/BindingOptions.cs`** +2. **`R3Ext.DynamicData/Binding/BindingOptions.cs`** - ~~Line 156: `propertyAccessor.Compile()(source)`~~ - **Context:** Used in WhenPropertyChanged to compile property access expression @@ -89,7 +89,7 @@ public static Observable> WhenPropertyChanged< **AOT Impact:** Runtime reflection on types/properties fails with trimming #### Locations: -1. **`R3.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs`** +1. **`R3Ext.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs`** - ~~Lines 233-260: `GetKeySelector()` method~~ - ~~Uses `typeof(TObject).GetProperty()` and `GetProperties()`~~ - ~~Searches for "Id", "[Key]" attribute, or naming patterns~~ @@ -157,7 +157,7 @@ The refactoring maintains the same functionality while being AOT-compatible: **AOT Impact:** Minor - GetType() for equality check #### Locations: -1. **`R3.DynamicData/Cache/Node.cs`** +1. **`R3Ext.DynamicData/Cache/Node.cs`** - ~~Line 156: `if (obj.GetType() != GetType())`~~ - **Context:** Equality comparison in Node @@ -199,7 +199,7 @@ The refactoring maintains the same functionality while being AOT-compatible: **AOT Impact:** None - Compile-time expression tree inspection is safe #### Locations: -1. **`R3.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs`** +1. **`R3Ext.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs`** - Line 22: `if (expression.Body is MemberExpression memberExpr)` - Line 27: `memberExpr.Member.Name` - reading property name from expression - **Context:** ExtractPropertyPath helper for source generator key matching @@ -255,7 +255,7 @@ var compiled = expression.Compile(); // โŒ Not AOT-safe ### Current AOT Settings: - **R3Ext.csproj**: `true` -- **R3.DynamicData.csproj**: No specific AOT settings +- **R3Ext.DynamicData.csproj**: No specific AOT settings ### Recommended Additions: ```xml @@ -333,7 +333,7 @@ var compiled = expression.Compile(); // โŒ Not AOT-safe ### Test Results - โœ… **R3Ext Tests**: 174/174 passing -- โœ… **R3.DynamicData Tests**: 285/285 passing +- โœ… **R3Ext.DynamicData Tests**: 285/285 passing - โœ… **Total**: 459/459 tests passing (100%) --- diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3d0aa71..fb4032a 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -75,9 +75,11 @@ When a tag starting with `v` is pushed: The following packages are published: - **R3Ext**: Core library with extensions +- **R3Ext.DynamicData**: DynamicData port โ€” reactive collections powered by R3 - **R3Ext.Bindings.SourceGenerator**: Data binding source generator - **R3Ext.Bindings.MauiTargets**: MAUI-specific build targets -- **R3Ext.PropertyChanged.SourceGenerator**: PropertyChanged source generator + +Any project with `true` in its `.csproj` is automatically picked up by CI without further workflow changes. ## GitHub Secrets @@ -92,15 +94,17 @@ Required secrets in repository settings: Test package creation locally: ```bash -# Restore and build -dotnet restore -dotnet build -c Release +# Restore packable libraries and test projects +PACKABLE=$(find . -name "*.csproj" -not -path "*/obj/*" | xargs grep -l 'true' | sort) +for proj in $PACKABLE; do dotnet restore "$proj"; done +dotnet restore R3Ext.Tests/R3Ext.Tests.csproj +dotnet restore R3Ext.DynamicData.Tests/R3Ext.DynamicData.Tests.csproj + +# Build packable libraries +for proj in $PACKABLE; do dotnet build "$proj" -c Release --no-restore; done # Create packages -dotnet pack R3Ext/R3Ext.csproj -c Release -o ./artifacts -dotnet pack R3Ext.Bindings.SourceGenerator/R3Ext.Bindings.SourceGenerator.csproj -c Release -o ./artifacts -dotnet pack R3Ext.Bindings.MauiTargets/R3Ext.Bindings.MauiTargets.csproj -c Release -o ./artifacts -dotnet pack R3Ext.PropertyChanged.SourceGenerator/R3Ext.PropertyChanged.SourceGenerator.csproj -c Release -o ./artifacts +for proj in $PACKABLE; do dotnet pack "$proj" -c Release --no-build -o ./artifacts; done # Inspect packages dotnet nuget verify ./artifacts/*.nupkg diff --git a/R3Ext.Benchmarks/Phase2CacheOperatorBenchmarks.cs b/R3Ext.Benchmarks/Phase2CacheOperatorBenchmarks.cs index 89ca163..7015adc 100644 --- a/R3Ext.Benchmarks/Phase2CacheOperatorBenchmarks.cs +++ b/R3Ext.Benchmarks/Phase2CacheOperatorBenchmarks.cs @@ -1,8 +1,8 @@ using BenchmarkDotNet.Attributes; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; -using R3.DynamicData.List; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; namespace R3Ext.Benchmarks; diff --git a/R3Ext.Benchmarks/R3Ext.Benchmarks.csproj b/R3Ext.Benchmarks/R3Ext.Benchmarks.csproj index fd1d608..3350911 100644 --- a/R3Ext.Benchmarks/R3Ext.Benchmarks.csproj +++ b/R3Ext.Benchmarks/R3Ext.Benchmarks.csproj @@ -30,7 +30,7 @@ - + /// Tests for logical operators using ToCollection() to verify UI binding behavior. diff --git a/R3.DynamicData.Tests/List/MergeChangeSetsTests.cs b/R3Ext.DynamicData.Tests/List/MergeChangeSetsTests.cs similarity index 98% rename from R3.DynamicData.Tests/List/MergeChangeSetsTests.cs rename to R3Ext.DynamicData.Tests/List/MergeChangeSetsTests.cs index 9768f35..1b5301b 100644 --- a/R3.DynamicData.Tests/List/MergeChangeSetsTests.cs +++ b/R3Ext.DynamicData.Tests/List/MergeChangeSetsTests.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; using Xunit; -namespace R3.DynamicData.Tests.List; +namespace R3Ext.DynamicData.Tests.List; public class MergeChangeSetsTests { diff --git a/R3.DynamicData.Tests/List/MergeManyOperatorTests.cs b/R3Ext.DynamicData.Tests/List/MergeManyOperatorTests.cs similarity index 97% rename from R3.DynamicData.Tests/List/MergeManyOperatorTests.cs rename to R3Ext.DynamicData.Tests/List/MergeManyOperatorTests.cs index d478e33..2c90a4f 100644 --- a/R3.DynamicData.Tests/List/MergeManyOperatorTests.cs +++ b/R3Ext.DynamicData.Tests/List/MergeManyOperatorTests.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Tests.List; +namespace R3Ext.DynamicData.Tests.List; public class MergeManyOperatorTests { diff --git a/R3.DynamicData.Tests/List/NewOperatorsTests.cs b/R3Ext.DynamicData.Tests/List/NewOperatorsTests.cs similarity index 99% rename from R3.DynamicData.Tests/List/NewOperatorsTests.cs rename to R3Ext.DynamicData.Tests/List/NewOperatorsTests.cs index 7e6823b..9ffa37a 100644 --- a/R3.DynamicData.Tests/List/NewOperatorsTests.cs +++ b/R3Ext.DynamicData.Tests/List/NewOperatorsTests.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3 - Tests. -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Tests.List; +namespace R3Ext.DynamicData.Tests.List; public sealed class NewOperatorsTests : IDisposable { diff --git a/R3.DynamicData.Tests/List/SortOperatorTests.cs b/R3Ext.DynamicData.Tests/List/SortOperatorTests.cs similarity index 99% rename from R3.DynamicData.Tests/List/SortOperatorTests.cs rename to R3Ext.DynamicData.Tests/List/SortOperatorTests.cs index 2f1418c..49432c9 100644 --- a/R3.DynamicData.Tests/List/SortOperatorTests.cs +++ b/R3Ext.DynamicData.Tests/List/SortOperatorTests.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Tests.List; +namespace R3Ext.DynamicData.Tests.List; public class SortOperatorTests { diff --git a/R3.DynamicData.Tests/List/SubscribeManyTests.cs b/R3Ext.DynamicData.Tests/List/SubscribeManyTests.cs similarity index 94% rename from R3.DynamicData.Tests/List/SubscribeManyTests.cs rename to R3Ext.DynamicData.Tests/List/SubscribeManyTests.cs index defa2a5..1d8f0cd 100644 --- a/R3.DynamicData.Tests/List/SubscribeManyTests.cs +++ b/R3Ext.DynamicData.Tests/List/SubscribeManyTests.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Tests.List; +namespace R3Ext.DynamicData.Tests.List; #pragma warning disable SA1516 #pragma warning disable SA1503 diff --git a/R3.DynamicData.Tests/List/TransformAsyncTests.cs b/R3Ext.DynamicData.Tests/List/TransformAsyncTests.cs similarity index 99% rename from R3.DynamicData.Tests/List/TransformAsyncTests.cs rename to R3Ext.DynamicData.Tests/List/TransformAsyncTests.cs index ed11518..7eb92ea 100644 --- a/R3.DynamicData.Tests/List/TransformAsyncTests.cs +++ b/R3Ext.DynamicData.Tests/List/TransformAsyncTests.cs @@ -1,9 +1,9 @@ using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; #pragma warning disable SA1503, SA1513, SA1515, SA1107, SA1502, SA1508, SA1516 -namespace R3.DynamicData.Tests.List; +namespace R3Ext.DynamicData.Tests.List; public class TransformAsyncTests { diff --git a/R3.DynamicData.Tests/List/TransformManyOperatorTests.cs b/R3Ext.DynamicData.Tests/List/TransformManyOperatorTests.cs similarity index 97% rename from R3.DynamicData.Tests/List/TransformManyOperatorTests.cs rename to R3Ext.DynamicData.Tests/List/TransformManyOperatorTests.cs index 05275ea..45f6762 100644 --- a/R3.DynamicData.Tests/List/TransformManyOperatorTests.cs +++ b/R3Ext.DynamicData.Tests/List/TransformManyOperatorTests.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Tests.List; +namespace R3Ext.DynamicData.Tests.List; public class TransformManyOperatorTests { diff --git a/R3.DynamicData.Tests/List/TransformOperatorTests.cs b/R3Ext.DynamicData.Tests/List/TransformOperatorTests.cs similarity index 94% rename from R3.DynamicData.Tests/List/TransformOperatorTests.cs rename to R3Ext.DynamicData.Tests/List/TransformOperatorTests.cs index 8a961ea..eb91bfa 100644 --- a/R3.DynamicData.Tests/List/TransformOperatorTests.cs +++ b/R3Ext.DynamicData.Tests/List/TransformOperatorTests.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Tests.List; +namespace R3Ext.DynamicData.Tests.List; public class TransformOperatorTests { diff --git a/R3.DynamicData.Tests/List/VirtualizeOperatorTests.cs b/R3Ext.DynamicData.Tests/List/VirtualizeOperatorTests.cs similarity index 99% rename from R3.DynamicData.Tests/List/VirtualizeOperatorTests.cs rename to R3Ext.DynamicData.Tests/List/VirtualizeOperatorTests.cs index 45b7c60..1d1bb93 100644 --- a/R3.DynamicData.Tests/List/VirtualizeOperatorTests.cs +++ b/R3Ext.DynamicData.Tests/List/VirtualizeOperatorTests.cs @@ -1,8 +1,8 @@ using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.List; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Tests.List; +namespace R3Ext.DynamicData.Tests.List; public class VirtualizeOperatorTests { diff --git a/R3.DynamicData.Tests/ListAggregateOperatorTests.cs b/R3Ext.DynamicData.Tests/ListAggregateOperatorTests.cs similarity index 98% rename from R3.DynamicData.Tests/ListAggregateOperatorTests.cs rename to R3Ext.DynamicData.Tests/ListAggregateOperatorTests.cs index 23c85dd..0a39e99 100644 --- a/R3.DynamicData.Tests/ListAggregateOperatorTests.cs +++ b/R3Ext.DynamicData.Tests/ListAggregateOperatorTests.cs @@ -1,10 +1,10 @@ // Port of DynamicData to R3. using System.ComponentModel; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; using Xunit; -namespace R3.DynamicData.Tests; +namespace R3Ext.DynamicData.Tests; public class ListAggregateOperatorTests { diff --git a/R3.DynamicData.Tests/ListChangeSetTests.cs b/R3Ext.DynamicData.Tests/ListChangeSetTests.cs similarity index 95% rename from R3.DynamicData.Tests/ListChangeSetTests.cs rename to R3Ext.DynamicData.Tests/ListChangeSetTests.cs index 226a66d..9488376 100644 --- a/R3.DynamicData.Tests/ListChangeSetTests.cs +++ b/R3Ext.DynamicData.Tests/ListChangeSetTests.cs @@ -1,6 +1,6 @@ -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Tests; +namespace R3Ext.DynamicData.Tests; public class ListChangeSetTests { diff --git a/R3.DynamicData.Tests/R3.DynamicData.Tests.csproj b/R3Ext.DynamicData.Tests/R3Ext.DynamicData.Tests.csproj similarity index 80% rename from R3.DynamicData.Tests/R3.DynamicData.Tests.csproj rename to R3Ext.DynamicData.Tests/R3Ext.DynamicData.Tests.csproj index 10e106d..4518a8e 100644 --- a/R3.DynamicData.Tests/R3.DynamicData.Tests.csproj +++ b/R3Ext.DynamicData.Tests/R3Ext.DynamicData.Tests.csproj @@ -24,9 +24,13 @@ + + - + diff --git a/R3.DynamicData.Tests/SourceCacheTests.cs b/R3Ext.DynamicData.Tests/SourceCacheTests.cs similarity index 98% rename from R3.DynamicData.Tests/SourceCacheTests.cs rename to R3Ext.DynamicData.Tests/SourceCacheTests.cs index 65150a0..9869e3a 100644 --- a/R3.DynamicData.Tests/SourceCacheTests.cs +++ b/R3Ext.DynamicData.Tests/SourceCacheTests.cs @@ -1,10 +1,10 @@ // Port of DynamicData to R3. using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Tests; +namespace R3Ext.DynamicData.Tests; public class SourceCacheTests { diff --git a/R3.DynamicData.Tests/SourceListTests.cs b/R3Ext.DynamicData.Tests/SourceListTests.cs similarity index 99% rename from R3.DynamicData.Tests/SourceListTests.cs rename to R3Ext.DynamicData.Tests/SourceListTests.cs index a7346ed..82475d7 100644 --- a/R3.DynamicData.Tests/SourceListTests.cs +++ b/R3Ext.DynamicData.Tests/SourceListTests.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Tests; +namespace R3Ext.DynamicData.Tests; public class SourceListTests { diff --git a/R3.DynamicData.Tests/TransformOperatorTests.cs b/R3Ext.DynamicData.Tests/TransformOperatorTests.cs similarity index 98% rename from R3.DynamicData.Tests/TransformOperatorTests.cs rename to R3Ext.DynamicData.Tests/TransformOperatorTests.cs index 52ec475..e5cf2ef 100644 --- a/R3.DynamicData.Tests/TransformOperatorTests.cs +++ b/R3Ext.DynamicData.Tests/TransformOperatorTests.cs @@ -1,11 +1,11 @@ // Port of DynamicData to R3. -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; -using R3.DynamicData.Operators; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.Operators; using Xunit; -namespace R3.DynamicData.Tests; +namespace R3Ext.DynamicData.Tests; public class TransformOperatorTests { diff --git a/R3.DynamicData/Binding/BindingOptions.cs b/R3Ext.DynamicData/Binding/BindingOptions.cs similarity index 98% rename from R3.DynamicData/Binding/BindingOptions.cs rename to R3Ext.DynamicData/Binding/BindingOptions.cs index cba63cf..b056c5b 100644 --- a/R3.DynamicData/Binding/BindingOptions.cs +++ b/R3Ext.DynamicData/Binding/BindingOptions.cs @@ -1,15 +1,15 @@ -// Supporting types for Bind operator in R3.DynamicData +// Supporting types for Bind operator in R3Ext.DynamicData using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Linq.Expressions; using R3; -using R3.DynamicData.Kernel; -using R3.DynamicData.List; using R3Ext; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Binding; +namespace R3Ext.DynamicData.Binding; /// /// Options for binding operations. diff --git a/R3.DynamicData/Cache/Change.cs b/R3Ext.DynamicData/Cache/Change.cs similarity index 98% rename from R3.DynamicData/Cache/Change.cs rename to R3Ext.DynamicData/Cache/Change.cs index a513808..fa204c9 100644 --- a/R3.DynamicData/Cache/Change.cs +++ b/R3Ext.DynamicData/Cache/Change.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Represents a single change to a cached item. diff --git a/R3.DynamicData/Cache/ChangeSet.cs b/R3Ext.DynamicData/Cache/ChangeSet.cs similarity index 98% rename from R3.DynamicData/Cache/ChangeSet.cs rename to R3Ext.DynamicData/Cache/ChangeSet.cs index 4021422..9950611 100644 --- a/R3.DynamicData/Cache/ChangeSet.cs +++ b/R3Ext.DynamicData/Cache/ChangeSet.cs @@ -2,7 +2,7 @@ using System.Collections; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// A collection of changes for a cache. diff --git a/R3.DynamicData/Cache/ICacheUpdater.cs b/R3Ext.DynamicData/Cache/ICacheUpdater.cs similarity index 98% rename from R3.DynamicData/Cache/ICacheUpdater.cs rename to R3Ext.DynamicData/Cache/ICacheUpdater.cs index 9237849..3d7fc78 100644 --- a/R3.DynamicData/Cache/ICacheUpdater.cs +++ b/R3Ext.DynamicData/Cache/ICacheUpdater.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// API for updating a cache within a batch operation. diff --git a/R3.DynamicData/Cache/IChangeSet.cs b/R3Ext.DynamicData/Cache/IChangeSet.cs similarity index 94% rename from R3.DynamicData/Cache/IChangeSet.cs rename to R3Ext.DynamicData/Cache/IChangeSet.cs index b230f54..aceeda8 100644 --- a/R3.DynamicData/Cache/IChangeSet.cs +++ b/R3Ext.DynamicData/Cache/IChangeSet.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// A collection of changes for a cache. diff --git a/R3.DynamicData/Cache/IGroup.cs b/R3Ext.DynamicData/Cache/IGroup.cs similarity index 96% rename from R3.DynamicData/Cache/IGroup.cs rename to R3Ext.DynamicData/Cache/IGroup.cs index b098221..3a361e2 100644 --- a/R3.DynamicData/Cache/IGroup.cs +++ b/R3Ext.DynamicData/Cache/IGroup.cs @@ -2,7 +2,7 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// An update stream which has been grouped by a common key. diff --git a/R3.DynamicData/Cache/IObservableCache.cs b/R3Ext.DynamicData/Cache/IObservableCache.cs similarity index 97% rename from R3.DynamicData/Cache/IObservableCache.cs rename to R3Ext.DynamicData/Cache/IObservableCache.cs index 220ad84..e1ad3f9 100644 --- a/R3.DynamicData/Cache/IObservableCache.cs +++ b/R3Ext.DynamicData/Cache/IObservableCache.cs @@ -2,9 +2,9 @@ // Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. // Roland Pheasant licenses this file to you under the MIT license. -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// A read-only observable cache for querying and observing in-memory data. diff --git a/R3.DynamicData/Cache/ISourceCache.cs b/R3Ext.DynamicData/Cache/ISourceCache.cs similarity index 99% rename from R3.DynamicData/Cache/ISourceCache.cs rename to R3Ext.DynamicData/Cache/ISourceCache.cs index a77c38d..0250215 100644 --- a/R3.DynamicData/Cache/ISourceCache.cs +++ b/R3Ext.DynamicData/Cache/ISourceCache.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// An editable cache that exposes an observable change set for tracking modifications. diff --git a/R3.DynamicData/Cache/Internal/AsyncDisposeMany.cs b/R3Ext.DynamicData/Cache/Internal/AsyncDisposeMany.cs similarity index 98% rename from R3.DynamicData/Cache/Internal/AsyncDisposeMany.cs rename to R3Ext.DynamicData/Cache/Internal/AsyncDisposeMany.cs index 81a26ec..acf5ba6 100644 --- a/R3.DynamicData/Cache/Internal/AsyncDisposeMany.cs +++ b/R3Ext.DynamicData/Cache/Internal/AsyncDisposeMany.cs @@ -1,5 +1,5 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class AsyncDisposeMany where TObject : notnull, IAsyncDisposable diff --git a/R3.DynamicData/Cache/Internal/AutoRefresh.cs b/R3Ext.DynamicData/Cache/Internal/AutoRefresh.cs similarity index 98% rename from R3.DynamicData/Cache/Internal/AutoRefresh.cs rename to R3Ext.DynamicData/Cache/Internal/AutoRefresh.cs index 41477ff..00ab848 100644 --- a/R3.DynamicData/Cache/Internal/AutoRefresh.cs +++ b/R3Ext.DynamicData/Cache/Internal/AutoRefresh.cs @@ -6,7 +6,7 @@ // R3 and disposed with their subscription. SAFE. using System.ComponentModel; -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class AutoRefresh where TObject : notnull diff --git a/R3.DynamicData/Cache/Internal/ChangeKeyOperator.cs b/R3Ext.DynamicData/Cache/Internal/ChangeKeyOperator.cs similarity index 98% rename from R3.DynamicData/Cache/Internal/ChangeKeyOperator.cs rename to R3Ext.DynamicData/Cache/Internal/ChangeKeyOperator.cs index 7e4c395..941feb4 100644 --- a/R3.DynamicData/Cache/Internal/ChangeKeyOperator.cs +++ b/R3Ext.DynamicData/Cache/Internal/ChangeKeyOperator.cs @@ -3,9 +3,9 @@ using System; using System.Collections.Generic; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class ChangeKeyOperator where TObject : notnull diff --git a/R3.DynamicData/Cache/Internal/DisposeMany.cs b/R3Ext.DynamicData/Cache/Internal/DisposeMany.cs similarity index 98% rename from R3.DynamicData/Cache/Internal/DisposeMany.cs rename to R3Ext.DynamicData/Cache/Internal/DisposeMany.cs index 8be0910..9440090 100644 --- a/R3.DynamicData/Cache/Internal/DisposeMany.cs +++ b/R3Ext.DynamicData/Cache/Internal/DisposeMany.cs @@ -1,5 +1,5 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class DisposeMany where TObject : notnull diff --git a/R3.DynamicData/Cache/Internal/EnsureUniqueKeys.cs b/R3Ext.DynamicData/Cache/Internal/EnsureUniqueKeys.cs similarity index 99% rename from R3.DynamicData/Cache/Internal/EnsureUniqueKeys.cs rename to R3Ext.DynamicData/Cache/Internal/EnsureUniqueKeys.cs index 7070f5d..f7081ee 100644 --- a/R3.DynamicData/Cache/Internal/EnsureUniqueKeys.cs +++ b/R3Ext.DynamicData/Cache/Internal/EnsureUniqueKeys.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using System.Linq; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class EnsureUniqueKeys where TObject : notnull diff --git a/R3.DynamicData/Cache/Internal/ExpireAfter.cs b/R3Ext.DynamicData/Cache/Internal/ExpireAfter.cs similarity index 99% rename from R3.DynamicData/Cache/Internal/ExpireAfter.cs rename to R3Ext.DynamicData/Cache/Internal/ExpireAfter.cs index 2d2767d..45a5d12 100644 --- a/R3.DynamicData/Cache/Internal/ExpireAfter.cs +++ b/R3Ext.DynamicData/Cache/Internal/ExpireAfter.cs @@ -3,7 +3,7 @@ // each added to the `disp` CompositeDisposable via .AddTo(disp). The Disposable.Create // cleanup explicitly iterates and disposes all timers and calls disp.Dispose(), so no // scheduler-held reference can outlive the operator subscription. SAFE. -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class ExpireAfter where TObject : notnull diff --git a/R3.DynamicData/Cache/Internal/FilterOnObservable.cs b/R3Ext.DynamicData/Cache/Internal/FilterOnObservable.cs similarity index 99% rename from R3.DynamicData/Cache/Internal/FilterOnObservable.cs rename to R3Ext.DynamicData/Cache/Internal/FilterOnObservable.cs index 58e1692..d19eceb 100644 --- a/R3.DynamicData/Cache/Internal/FilterOnObservable.cs +++ b/R3Ext.DynamicData/Cache/Internal/FilterOnObservable.cs @@ -1,5 +1,5 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class FilterOnObservable where TKey : notnull diff --git a/R3.DynamicData/Cache/Internal/IncludeUpdateWhen.cs b/R3Ext.DynamicData/Cache/Internal/IncludeUpdateWhen.cs similarity index 98% rename from R3.DynamicData/Cache/Internal/IncludeUpdateWhen.cs rename to R3Ext.DynamicData/Cache/Internal/IncludeUpdateWhen.cs index b621a93..db0b26a 100644 --- a/R3.DynamicData/Cache/Internal/IncludeUpdateWhen.cs +++ b/R3Ext.DynamicData/Cache/Internal/IncludeUpdateWhen.cs @@ -1,6 +1,6 @@ // Port of DynamicData IncludeUpdateWhen to R3. -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class IncludeUpdateWhen where TObject : notnull diff --git a/R3.DynamicData/Cache/Internal/ManagedGroup.cs b/R3Ext.DynamicData/Cache/Internal/ManagedGroup.cs similarity index 98% rename from R3.DynamicData/Cache/Internal/ManagedGroup.cs rename to R3Ext.DynamicData/Cache/Internal/ManagedGroup.cs index cdc69ac..d793ef7 100644 --- a/R3.DynamicData/Cache/Internal/ManagedGroup.cs +++ b/R3Ext.DynamicData/Cache/Internal/ManagedGroup.cs @@ -5,7 +5,7 @@ using System; using System.Collections.Generic; -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class ManagedGroup : IGroup, IDisposable where TObject : notnull diff --git a/R3.DynamicData/Cache/Internal/SubscribeMany.cs b/R3Ext.DynamicData/Cache/Internal/SubscribeMany.cs similarity index 99% rename from R3.DynamicData/Cache/Internal/SubscribeMany.cs rename to R3Ext.DynamicData/Cache/Internal/SubscribeMany.cs index 66f9eb8..2c21b29 100644 --- a/R3.DynamicData/Cache/Internal/SubscribeMany.cs +++ b/R3Ext.DynamicData/Cache/Internal/SubscribeMany.cs @@ -1,5 +1,5 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class SubscribeMany where TKey : notnull diff --git a/R3.DynamicData/Cache/Internal/SuppressRefresh.cs b/R3Ext.DynamicData/Cache/Internal/SuppressRefresh.cs similarity index 97% rename from R3.DynamicData/Cache/Internal/SuppressRefresh.cs rename to R3Ext.DynamicData/Cache/Internal/SuppressRefresh.cs index 0254822..7bd77d1 100644 --- a/R3.DynamicData/Cache/Internal/SuppressRefresh.cs +++ b/R3Ext.DynamicData/Cache/Internal/SuppressRefresh.cs @@ -1,7 +1,7 @@ // Port of DynamicData SuppressRefresh to R3. // Style suppression for internal operator implementation. -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class SuppressRefresh where TObject : notnull diff --git a/R3.DynamicData/Cache/Internal/TransformOnObservable.cs b/R3Ext.DynamicData/Cache/Internal/TransformOnObservable.cs similarity index 99% rename from R3.DynamicData/Cache/Internal/TransformOnObservable.cs rename to R3Ext.DynamicData/Cache/Internal/TransformOnObservable.cs index 928ce31..f88571c 100644 --- a/R3.DynamicData/Cache/Internal/TransformOnObservable.cs +++ b/R3Ext.DynamicData/Cache/Internal/TransformOnObservable.cs @@ -1,5 +1,5 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; internal sealed class TransformOnObservable where TObject : notnull diff --git a/R3.DynamicData/Cache/Internal/TreeBuilder.cs b/R3Ext.DynamicData/Cache/Internal/TreeBuilder.cs similarity index 98% rename from R3.DynamicData/Cache/Internal/TreeBuilder.cs rename to R3Ext.DynamicData/Cache/Internal/TreeBuilder.cs index 8276ace..1d23d3c 100644 --- a/R3.DynamicData/Cache/Internal/TreeBuilder.cs +++ b/R3Ext.DynamicData/Cache/Internal/TreeBuilder.cs @@ -8,10 +8,10 @@ using System; using System.Linq; using R3; -using R3.DynamicData.Kernel; -using R3.DynamicData.Operators; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.Operators; -namespace R3.DynamicData.Cache.Internal; +namespace R3Ext.DynamicData.Cache.Internal; /// /// Internal class that builds and maintains a tree structure from a flat changeset. diff --git a/R3.DynamicData/Cache/Node.cs b/R3Ext.DynamicData/Cache/Node.cs similarity index 98% rename from R3.DynamicData/Cache/Node.cs rename to R3Ext.DynamicData/Cache/Node.cs index 6194465..9ae9900 100644 --- a/R3.DynamicData/Cache/Node.cs +++ b/R3Ext.DynamicData/Cache/Node.cs @@ -2,9 +2,9 @@ // Copyright (c) 2011-2023 Roland Pheasant. All rights reserved. // Roland Pheasant licenses this file to you under the MIT license. -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Node describing the relationship between an item and its ancestors and descendants. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.Batch.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.Batch.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheEx.Batch.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.Batch.cs index d9e47bd..1f8c039 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.Batch.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.Batch.cs @@ -5,7 +5,7 @@ using System; using System.Collections.Generic; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for observable cache change sets. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.ChangeKey.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.ChangeKey.cs similarity index 93% rename from R3.DynamicData/Cache/ObservableCacheEx.ChangeKey.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.ChangeKey.cs index 9b84e66..62e2875 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.ChangeKey.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.ChangeKey.cs @@ -1,9 +1,9 @@ // Public extension for ChangeKey operator. using System; -using R3.DynamicData.Cache.Internal; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Cache.Internal; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for cache change key operations. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.Convenience.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.Convenience.cs similarity index 98% rename from R3.DynamicData/Cache/ObservableCacheEx.Convenience.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.Convenience.cs index af16c6c..b9aae11 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.Convenience.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.Convenience.cs @@ -1,7 +1,7 @@ // Additional convenience extensions for SourceCache operations. // Phase 1: expose predicate-based removals and batching helpers matching internal updater capabilities. -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// Extension methods for convenient cache operations. public static partial class ObservableCacheEx diff --git a/R3.DynamicData/Cache/ObservableCacheEx.Filter.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.Filter.cs similarity index 98% rename from R3.DynamicData/Cache/ObservableCacheEx.Filter.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.Filter.cs index f30d23c..59bf22f 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.Filter.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.Filter.cs @@ -3,9 +3,9 @@ // - DD #1013: No bogus no-predicate overload exists here; only FilterCacheInternal which requires a predicate. Not affected. // - DD #1048: ChangeReason.Refresh is handled: re-evaluates predicate and emits Refresh (still passes), // Remove (stops passing), or Refresh (starts passing). Implementation is correct. -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for cache filtering operations. @@ -20,7 +20,7 @@ public static partial class ObservableCacheEx /// The source observable cache. /// The predicate to filter items. /// An observable that emits filtered change sets. - /// Renamed to avoid ambiguity with R3.DynamicData.Operators.FilterOperator.Filter. + /// Renamed to avoid ambiguity with R3Ext.DynamicData.Operators.FilterOperator.Filter. public static Observable> FilterCacheInternal( this Observable> source, Func predicate) diff --git a/R3.DynamicData/Cache/ObservableCacheEx.Grouping.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.Grouping.cs similarity index 98% rename from R3.DynamicData/Cache/ObservableCacheEx.Grouping.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.Grouping.cs index e28ce9c..a63d629 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.Grouping.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.Grouping.cs @@ -1,4 +1,4 @@ -// Upgraded Group operator implementation for R3.DynamicData +// Upgraded Group operator implementation for R3Ext.DynamicData // Audited against DD #938 (GroupOnObservable OnCompleted handling fix). // The Subscribe call passes observer.OnCompleted as the completion handler, so when the source // completes the downstream observer is completed correctly. Not affected by DD #938. @@ -6,11 +6,11 @@ using System; using System.Collections.Generic; using System.Linq; -using R3.DynamicData.Cache.Internal; -using R3.DynamicData.Kernel; -using R3.DynamicData.List; +using R3Ext.DynamicData.Cache.Internal; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for observable cache change sets. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.Joins.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.Joins.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheEx.Joins.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.Joins.cs index e479e53..e44f788 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.Joins.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.Joins.cs @@ -8,9 +8,9 @@ // then calling RecomputeAndEmit(), which removes results for stale keys and adds results for new // overlapping keys. Foreign key changes are handled correctly. // Provides diff emission (Add/Update/Remove) for joined pairs. Future work: Left/Right/Full joins and Many joins. -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for observable cache change sets. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.Phase2.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.Phase2.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheEx.Phase2.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.Phase2.cs index d9f197d..c0d6649 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.Phase2.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.Phase2.cs @@ -2,11 +2,11 @@ using System.Collections.Generic; using System.Linq; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; -using R3.DynamicData.List; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; // Phase 2 cache operators for R3 port. // NOTE: Adapted from DynamicData concepts; simplified to match R3's Observable (not System.IObservable). Inner observable completions NEVER complete outer streams. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.Sort.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.Sort.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheEx.Sort.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.Sort.cs index c968abf..80ed463 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.Sort.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.Sort.cs @@ -1,9 +1,9 @@ // Port of DynamicData to R3. -using R3.DynamicData.Kernel; -using R3.DynamicData.List; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for observable cache change sets. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.SortAndBind.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.SortAndBind.cs similarity index 98% rename from R3.DynamicData/Cache/ObservableCacheEx.SortAndBind.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.SortAndBind.cs index f9339ea..c96fb9d 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.SortAndBind.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.SortAndBind.cs @@ -4,12 +4,12 @@ // full reset on the first changeset when the flag is set, regardless of ResetThreshold. // Provides lower allocation and avoids transmitting full sorted state via intermediate sorted change sets. using System.Collections.ObjectModel; -using R3.DynamicData.Binding; -using R3.DynamicData.Kernel; -using R3.DynamicData.List; -using R3.DynamicData.List.Internal; +using R3Ext.DynamicData.Binding; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; +using R3Ext.DynamicData.List.Internal; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for observable cache change sets. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.TransformAsync.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.TransformAsync.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheEx.TransformAsync.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.TransformAsync.cs index e0c19cf..dcff5e4 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.TransformAsync.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.TransformAsync.cs @@ -6,7 +6,7 @@ using System.Threading; using System.Threading.Tasks; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// Async transformation extension methods for observable caches. public static partial class ObservableCacheEx diff --git a/R3.DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs similarity index 97% rename from R3.DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs index fcfb119..bd739db 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.TransformOnObservable.cs @@ -1,5 +1,5 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for observable cache change sets. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.TransformSafe.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.TransformSafe.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheEx.TransformSafe.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.TransformSafe.cs index 3f193ea..77d52f0 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.TransformSafe.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.TransformSafe.cs @@ -1,8 +1,8 @@ // Port of DynamicData TransformSafe to R3. using System; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for observable cache change sets. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.Virtualize.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.Virtualize.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheEx.Virtualize.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.Virtualize.cs index 4ca16c0..3470595 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.Virtualize.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.Virtualize.cs @@ -1,9 +1,9 @@ // Port of DynamicData to R3. using System.Collections.Generic; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for observable cache change sets. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs index 350218d..a8aba26 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs @@ -10,10 +10,10 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Text; -using R3.DynamicData.Kernel; using R3Ext; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for observable cache change sets. diff --git a/R3.DynamicData/Cache/ObservableCacheEx.cs b/R3Ext.DynamicData/Cache/ObservableCacheEx.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheEx.cs rename to R3Ext.DynamicData/Cache/ObservableCacheEx.cs index 56c893b..618e947 100644 --- a/R3.DynamicData/Cache/ObservableCacheEx.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheEx.cs @@ -3,11 +3,11 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq.Expressions; -using R3.DynamicData.Binding; -using R3.DynamicData.Kernel; -using R3.DynamicData.List; +using R3Ext.DynamicData.Binding; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Observable cache extension methods. diff --git a/R3.DynamicData/Cache/ObservableCacheExTreeTransformation.cs b/R3Ext.DynamicData/Cache/ObservableCacheExTreeTransformation.cs similarity index 96% rename from R3.DynamicData/Cache/ObservableCacheExTreeTransformation.cs rename to R3Ext.DynamicData/Cache/ObservableCacheExTreeTransformation.cs index 26e5e8a..8776d7d 100644 --- a/R3.DynamicData/Cache/ObservableCacheExTreeTransformation.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheExTreeTransformation.cs @@ -2,10 +2,10 @@ using System; using R3; -using R3.DynamicData.Cache.Internal; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Cache.Internal; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extension methods for tree transformation operators. diff --git a/R3.DynamicData/Cache/ObservableCacheExtensions.cs b/R3Ext.DynamicData/Cache/ObservableCacheExtensions.cs similarity index 99% rename from R3.DynamicData/Cache/ObservableCacheExtensions.cs rename to R3Ext.DynamicData/Cache/ObservableCacheExtensions.cs index ed6af14..8397608 100644 --- a/R3.DynamicData/Cache/ObservableCacheExtensions.cs +++ b/R3Ext.DynamicData/Cache/ObservableCacheExtensions.cs @@ -3,9 +3,9 @@ // Roland Pheasant licenses this file to you under the MIT license. using System.Collections.Concurrent; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Extensions for creating observable caches. diff --git a/R3.DynamicData/Cache/SortAndBindOptions.cs b/R3Ext.DynamicData/Cache/SortAndBindOptions.cs similarity index 95% rename from R3.DynamicData/Cache/SortAndBindOptions.cs rename to R3Ext.DynamicData/Cache/SortAndBindOptions.cs index 86159e5..439de70 100644 --- a/R3.DynamicData/Cache/SortAndBindOptions.cs +++ b/R3Ext.DynamicData/Cache/SortAndBindOptions.cs @@ -1,8 +1,8 @@ // Combined Sort + Bind operator for cache side (inspired by DynamicData SortAndBind). -using R3.DynamicData.Binding; +using R3Ext.DynamicData.Binding; -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// Options for SortAndBind optimization. diff --git a/R3.DynamicData/Cache/SourceCache.cs b/R3Ext.DynamicData/Cache/SourceCache.cs similarity index 99% rename from R3.DynamicData/Cache/SourceCache.cs rename to R3Ext.DynamicData/Cache/SourceCache.cs index f72bce6..af1806b 100644 --- a/R3.DynamicData/Cache/SourceCache.cs +++ b/R3Ext.DynamicData/Cache/SourceCache.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Cache; +namespace R3Ext.DynamicData.Cache; /// /// An observable cache that tracks changes to items with unique keys. diff --git a/R3.DynamicData/IChangeSet.cs b/R3Ext.DynamicData/IChangeSet.cs similarity index 97% rename from R3.DynamicData/IChangeSet.cs rename to R3Ext.DynamicData/IChangeSet.cs index da0868b..418d3a4 100644 --- a/R3.DynamicData/IChangeSet.cs +++ b/R3Ext.DynamicData/IChangeSet.cs @@ -1,7 +1,7 @@ // This is a port of DynamicData to R3 for enhanced performance. // Original DynamicData: Copyright (c) 2011-2023 Roland Pheasant. Licensed under the MIT license. -namespace R3.DynamicData; +namespace R3Ext.DynamicData; /// /// Base interface representing a set of changes. diff --git a/R3.DynamicData/Kernel/ChangeReason.cs b/R3Ext.DynamicData/Kernel/ChangeReason.cs similarity index 94% rename from R3.DynamicData/Kernel/ChangeReason.cs rename to R3Ext.DynamicData/Kernel/ChangeReason.cs index 52d1009..c922eb4 100644 --- a/R3.DynamicData/Kernel/ChangeReason.cs +++ b/R3Ext.DynamicData/Kernel/ChangeReason.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Kernel; +namespace R3Ext.DynamicData.Kernel; /// /// The reason for a change in a collection. diff --git a/R3.DynamicData/Kernel/Error.cs b/R3Ext.DynamicData/Kernel/Error.cs similarity index 97% rename from R3.DynamicData/Kernel/Error.cs rename to R3Ext.DynamicData/Kernel/Error.cs index f725722..6ed79dd 100644 --- a/R3.DynamicData/Kernel/Error.cs +++ b/R3Ext.DynamicData/Kernel/Error.cs @@ -1,5 +1,5 @@ // Port of DynamicData Error to R3. -namespace R3.DynamicData.Kernel; +namespace R3Ext.DynamicData.Kernel; /// /// Container for an error that occurred during transformation with the source item and key. diff --git a/R3.DynamicData/Kernel/Optional.cs b/R3Ext.DynamicData/Kernel/Optional.cs similarity index 99% rename from R3.DynamicData/Kernel/Optional.cs rename to R3Ext.DynamicData/Kernel/Optional.cs index c825c7f..d8da63a 100644 --- a/R3.DynamicData/Kernel/Optional.cs +++ b/R3Ext.DynamicData/Kernel/Optional.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Kernel; +namespace R3Ext.DynamicData.Kernel; /// /// Represents an optional value that may or may not be present. diff --git a/R3.DynamicData/List/Change.cs b/R3Ext.DynamicData/List/Change.cs similarity index 99% rename from R3.DynamicData/List/Change.cs rename to R3Ext.DynamicData/List/Change.cs index 2b22a6d..0c993a9 100644 --- a/R3.DynamicData/List/Change.cs +++ b/R3Ext.DynamicData/List/Change.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Represents a single change to a list. diff --git a/R3.DynamicData/List/ChangeAwareList.cs b/R3Ext.DynamicData/List/ChangeAwareList.cs similarity index 99% rename from R3.DynamicData/List/ChangeAwareList.cs rename to R3Ext.DynamicData/List/ChangeAwareList.cs index 79a7cc8..4f0ffe2 100644 --- a/R3.DynamicData/List/ChangeAwareList.cs +++ b/R3Ext.DynamicData/List/ChangeAwareList.cs @@ -2,7 +2,7 @@ using System.Collections; -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// A list which captures all changes made to it. diff --git a/R3.DynamicData/List/ChangeSet.cs b/R3Ext.DynamicData/List/ChangeSet.cs similarity index 99% rename from R3.DynamicData/List/ChangeSet.cs rename to R3Ext.DynamicData/List/ChangeSet.cs index d7791d7..532b25e 100644 --- a/R3.DynamicData/List/ChangeSet.cs +++ b/R3Ext.DynamicData/List/ChangeSet.cs @@ -2,7 +2,7 @@ using System.Collections; -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Represents a collection of changes to a list. diff --git a/R3.DynamicData/List/Group.cs b/R3Ext.DynamicData/List/Group.cs similarity index 96% rename from R3.DynamicData/List/Group.cs rename to R3Ext.DynamicData/List/Group.cs index c2de669..25f9063 100644 --- a/R3.DynamicData/List/Group.cs +++ b/R3Ext.DynamicData/List/Group.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Represents a group of items with a common key. diff --git a/R3.DynamicData/List/IChangeSet.cs b/R3Ext.DynamicData/List/IChangeSet.cs similarity index 96% rename from R3.DynamicData/List/IChangeSet.cs rename to R3Ext.DynamicData/List/IChangeSet.cs index b8ffc9d..bc8c87c 100644 --- a/R3.DynamicData/List/IChangeSet.cs +++ b/R3Ext.DynamicData/List/IChangeSet.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// A collection of changes for a list. diff --git a/R3.DynamicData/List/IExtendedList.cs b/R3Ext.DynamicData/List/IExtendedList.cs similarity index 97% rename from R3.DynamicData/List/IExtendedList.cs rename to R3Ext.DynamicData/List/IExtendedList.cs index b93a8b4..458657a 100644 --- a/R3.DynamicData/List/IExtendedList.cs +++ b/R3Ext.DynamicData/List/IExtendedList.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Represents a list which supports range operations. diff --git a/R3.DynamicData/List/ISourceList.cs b/R3Ext.DynamicData/List/ISourceList.cs similarity index 99% rename from R3.DynamicData/List/ISourceList.cs rename to R3Ext.DynamicData/List/ISourceList.cs index 69e89a0..0800c0b 100644 --- a/R3.DynamicData/List/ISourceList.cs +++ b/R3Ext.DynamicData/List/ISourceList.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// A reactive list that publishes changes as an observable change set. diff --git a/R3.DynamicData/List/Internal/AddKey.cs b/R3Ext.DynamicData/List/Internal/AddKey.cs similarity index 98% rename from R3.DynamicData/List/Internal/AddKey.cs rename to R3Ext.DynamicData/List/Internal/AddKey.cs index d3fee20..bb1fd35 100644 --- a/R3.DynamicData/List/Internal/AddKey.cs +++ b/R3Ext.DynamicData/List/Internal/AddKey.cs @@ -2,10 +2,10 @@ using System; using System.Collections.Generic; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class AddKey where TObject : notnull diff --git a/R3.DynamicData/List/Internal/AsyncDisposeMany.cs b/R3Ext.DynamicData/List/Internal/AsyncDisposeMany.cs similarity index 99% rename from R3.DynamicData/List/Internal/AsyncDisposeMany.cs rename to R3Ext.DynamicData/List/Internal/AsyncDisposeMany.cs index 3626e46..7908ceb 100644 --- a/R3.DynamicData/List/Internal/AsyncDisposeMany.cs +++ b/R3Ext.DynamicData/List/Internal/AsyncDisposeMany.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class AsyncDisposeMany where T : notnull, IAsyncDisposable diff --git a/R3.DynamicData/List/Internal/AutoRefresh.cs b/R3Ext.DynamicData/List/Internal/AutoRefresh.cs similarity index 99% rename from R3.DynamicData/List/Internal/AutoRefresh.cs rename to R3Ext.DynamicData/List/Internal/AutoRefresh.cs index 05d24e1..db4a287 100644 --- a/R3.DynamicData/List/Internal/AutoRefresh.cs +++ b/R3Ext.DynamicData/List/Internal/AutoRefresh.cs @@ -9,7 +9,7 @@ using System.Linq; using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class AutoRefresh where TObject : notnull diff --git a/R3.DynamicData/List/Internal/BufferIf.cs b/R3Ext.DynamicData/List/Internal/BufferIf.cs similarity index 98% rename from R3.DynamicData/List/Internal/BufferIf.cs rename to R3Ext.DynamicData/List/Internal/BufferIf.cs index 62a2b4a..4984614 100644 --- a/R3.DynamicData/List/Internal/BufferIf.cs +++ b/R3Ext.DynamicData/List/Internal/BufferIf.cs @@ -3,7 +3,7 @@ using System; using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class BufferIf(Observable> source, Observable pauseIfTrueSelector, bool initialPauseState = false, TimeSpan? timeOut = null) where T : notnull diff --git a/R3.DynamicData/List/Internal/Combiner.cs b/R3Ext.DynamicData/List/Internal/Combiner.cs similarity index 99% rename from R3.DynamicData/List/Internal/Combiner.cs rename to R3Ext.DynamicData/List/Internal/Combiner.cs index f740a94..1f28077 100644 --- a/R3.DynamicData/List/Internal/Combiner.cs +++ b/R3Ext.DynamicData/List/Internal/Combiner.cs @@ -1,6 +1,6 @@ using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal enum CombineOperator { diff --git a/R3.DynamicData/List/Internal/DeferUntilLoaded.cs b/R3Ext.DynamicData/List/Internal/DeferUntilLoaded.cs similarity index 95% rename from R3.DynamicData/List/Internal/DeferUntilLoaded.cs rename to R3Ext.DynamicData/List/Internal/DeferUntilLoaded.cs index 49637d7..59af786 100644 --- a/R3.DynamicData/List/Internal/DeferUntilLoaded.cs +++ b/R3Ext.DynamicData/List/Internal/DeferUntilLoaded.cs @@ -1,6 +1,6 @@ using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class DeferUntilLoaded where T : notnull diff --git a/R3.DynamicData/List/Internal/DisposeMany.cs b/R3Ext.DynamicData/List/Internal/DisposeMany.cs similarity index 99% rename from R3.DynamicData/List/Internal/DisposeMany.cs rename to R3Ext.DynamicData/List/Internal/DisposeMany.cs index 8e14148..c3eb1a2 100644 --- a/R3.DynamicData/List/Internal/DisposeMany.cs +++ b/R3Ext.DynamicData/List/Internal/DisposeMany.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class DisposeMany where T : notnull diff --git a/R3.DynamicData/List/Internal/DistinctValues.cs b/R3Ext.DynamicData/List/Internal/DistinctValues.cs similarity index 99% rename from R3.DynamicData/List/Internal/DistinctValues.cs rename to R3Ext.DynamicData/List/Internal/DistinctValues.cs index 5d3a10f..dbd5483 100644 --- a/R3.DynamicData/List/Internal/DistinctValues.cs +++ b/R3Ext.DynamicData/List/Internal/DistinctValues.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class DistinctValues where TValue : notnull diff --git a/R3.DynamicData/List/Internal/DynamicFilter.cs b/R3Ext.DynamicData/List/Internal/DynamicFilter.cs similarity index 99% rename from R3.DynamicData/List/Internal/DynamicFilter.cs rename to R3Ext.DynamicData/List/Internal/DynamicFilter.cs index 65ee3a4..910028a 100644 --- a/R3.DynamicData/List/Internal/DynamicFilter.cs +++ b/R3Ext.DynamicData/List/Internal/DynamicFilter.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class DynamicFilter { diff --git a/R3.DynamicData/List/Internal/Filter.cs b/R3Ext.DynamicData/List/Internal/Filter.cs similarity index 99% rename from R3.DynamicData/List/Internal/Filter.cs rename to R3Ext.DynamicData/List/Internal/Filter.cs index a5e537a..bc06e46 100644 --- a/R3.DynamicData/List/Internal/Filter.cs +++ b/R3Ext.DynamicData/List/Internal/Filter.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class Filter { diff --git a/R3.DynamicData/List/Internal/GroupBy.cs b/R3Ext.DynamicData/List/Internal/GroupBy.cs similarity index 99% rename from R3.DynamicData/List/Internal/GroupBy.cs rename to R3Ext.DynamicData/List/Internal/GroupBy.cs index 7a0de58..10f5af7 100644 --- a/R3.DynamicData/List/Internal/GroupBy.cs +++ b/R3Ext.DynamicData/List/Internal/GroupBy.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class GroupBy where TKey : notnull diff --git a/R3.DynamicData/List/Internal/MergeMany.cs b/R3Ext.DynamicData/List/Internal/MergeMany.cs similarity index 97% rename from R3.DynamicData/List/Internal/MergeMany.cs rename to R3Ext.DynamicData/List/Internal/MergeMany.cs index e42ed54..a7fc967 100644 --- a/R3.DynamicData/List/Internal/MergeMany.cs +++ b/R3Ext.DynamicData/List/Internal/MergeMany.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class MergeMany where TSource : notnull diff --git a/R3.DynamicData/List/Internal/OnBeingAdded.cs b/R3Ext.DynamicData/List/Internal/OnBeingAdded.cs similarity index 95% rename from R3.DynamicData/List/Internal/OnBeingAdded.cs rename to R3Ext.DynamicData/List/Internal/OnBeingAdded.cs index 7afc53a..7084d10 100644 --- a/R3.DynamicData/List/Internal/OnBeingAdded.cs +++ b/R3Ext.DynamicData/List/Internal/OnBeingAdded.cs @@ -1,6 +1,6 @@ using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class OnBeingAdded where T : notnull diff --git a/R3.DynamicData/List/Internal/OnBeingRemoved.cs b/R3Ext.DynamicData/List/Internal/OnBeingRemoved.cs similarity index 98% rename from R3.DynamicData/List/Internal/OnBeingRemoved.cs rename to R3Ext.DynamicData/List/Internal/OnBeingRemoved.cs index 90c8b06..b2bcf02 100644 --- a/R3.DynamicData/List/Internal/OnBeingRemoved.cs +++ b/R3Ext.DynamicData/List/Internal/OnBeingRemoved.cs @@ -1,6 +1,6 @@ using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class OnBeingRemoved where T : notnull diff --git a/R3.DynamicData/List/Internal/QueryWhenChanged.cs b/R3Ext.DynamicData/List/Internal/QueryWhenChanged.cs similarity index 98% rename from R3.DynamicData/List/Internal/QueryWhenChanged.cs rename to R3Ext.DynamicData/List/Internal/QueryWhenChanged.cs index 9e595da..e326d98 100644 --- a/R3.DynamicData/List/Internal/QueryWhenChanged.cs +++ b/R3Ext.DynamicData/List/Internal/QueryWhenChanged.cs @@ -1,6 +1,6 @@ using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class QueryWhenChanged where T : notnull diff --git a/R3.DynamicData/List/Internal/RefCount.cs b/R3Ext.DynamicData/List/Internal/RefCount.cs similarity index 88% rename from R3.DynamicData/List/Internal/RefCount.cs rename to R3Ext.DynamicData/List/Internal/RefCount.cs index 6f26746..1b0434f 100644 --- a/R3.DynamicData/List/Internal/RefCount.cs +++ b/R3Ext.DynamicData/List/Internal/RefCount.cs @@ -1,6 +1,6 @@ using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class RefCount where T : notnull diff --git a/R3.DynamicData/List/Internal/RemoveIndex.cs b/R3Ext.DynamicData/List/Internal/RemoveIndex.cs similarity index 98% rename from R3.DynamicData/List/Internal/RemoveIndex.cs rename to R3Ext.DynamicData/List/Internal/RemoveIndex.cs index 9b2597e..292b1aa 100644 --- a/R3.DynamicData/List/Internal/RemoveIndex.cs +++ b/R3Ext.DynamicData/List/Internal/RemoveIndex.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class RemoveIndex { diff --git a/R3.DynamicData/List/Internal/Reverse.cs b/R3Ext.DynamicData/List/Internal/Reverse.cs similarity index 99% rename from R3.DynamicData/List/Internal/Reverse.cs rename to R3Ext.DynamicData/List/Internal/Reverse.cs index 5452866..f339076 100644 --- a/R3.DynamicData/List/Internal/Reverse.cs +++ b/R3Ext.DynamicData/List/Internal/Reverse.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class Reverse { diff --git a/R3.DynamicData/List/Internal/Sort.cs b/R3Ext.DynamicData/List/Internal/Sort.cs similarity index 99% rename from R3.DynamicData/List/Internal/Sort.cs rename to R3Ext.DynamicData/List/Internal/Sort.cs index fb9626f..6a1285e 100644 --- a/R3.DynamicData/List/Internal/Sort.cs +++ b/R3Ext.DynamicData/List/Internal/Sort.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class Sort where T : notnull diff --git a/R3.DynamicData/List/Internal/StatefulFilter.cs b/R3Ext.DynamicData/List/Internal/StatefulFilter.cs similarity index 99% rename from R3.DynamicData/List/Internal/StatefulFilter.cs rename to R3Ext.DynamicData/List/Internal/StatefulFilter.cs index d4c9cbb..09129f4 100644 --- a/R3.DynamicData/List/Internal/StatefulFilter.cs +++ b/R3Ext.DynamicData/List/Internal/StatefulFilter.cs @@ -1,5 +1,5 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; // Audited DD #941: StatefulFilter avoids allocating a new predicate delegate on each state change // by accepting the state as a separate observable and a (T, TState) โ†’ bool predicate. diff --git a/R3.DynamicData/List/Internal/SubscribeMany.cs b/R3Ext.DynamicData/List/Internal/SubscribeMany.cs similarity index 99% rename from R3.DynamicData/List/Internal/SubscribeMany.cs rename to R3Ext.DynamicData/List/Internal/SubscribeMany.cs index 811186b..7383e03 100644 --- a/R3.DynamicData/List/Internal/SubscribeMany.cs +++ b/R3Ext.DynamicData/List/Internal/SubscribeMany.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class SubscribeMany where T : notnull diff --git a/R3.DynamicData/List/Internal/Switch.cs b/R3Ext.DynamicData/List/Internal/Switch.cs similarity index 61% rename from R3.DynamicData/List/Internal/Switch.cs rename to R3Ext.DynamicData/List/Internal/Switch.cs index 9a39859..c321da3 100644 --- a/R3.DynamicData/List/Internal/Switch.cs +++ b/R3Ext.DynamicData/List/Internal/Switch.cs @@ -1,13 +1,15 @@ // Port of DynamicData to R3. // Audited against DD #968 (Switch error propagation fix). -// The inner source is subscribed directly with `innerSource.Subscribe(observer)`, which routes -// OnNext, OnError, and OnCompleted from the inner source directly to the downstream observer. -// Errors are therefore propagated correctly; this implementation is NOT affected by DD #968. +// Each inner source is subscribed with a fresh forwarding observer (lambdas) rather than by +// reusing the single downstream observer instance: R3 observers are single-subscription, so +// reusing the same observer across successive inner sources throws on the second subscribe. +// OnNext and OnErrorResume are forwarded downstream; inner OnCompleted is intentionally ignored +// so the merged stream only completes when the outer source of sources completes. using System; using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class Switch(Observable>> sources) where T : notnull @@ -26,7 +28,10 @@ public Observable> Run() => Observable.Create>( { lock (locker) { - currentSubscription.Disposable = innerSource.Subscribe(observer); + currentSubscription.Disposable = innerSource.Subscribe( + value => observer.OnNext(value), + error => observer.OnErrorResume(error), + static _ => { }); } }, observer.OnErrorResume, diff --git a/R3.DynamicData/List/Internal/ToObservableChangeSet.cs b/R3Ext.DynamicData/List/Internal/ToObservableChangeSet.cs similarity index 99% rename from R3.DynamicData/List/Internal/ToObservableChangeSet.cs rename to R3Ext.DynamicData/List/Internal/ToObservableChangeSet.cs index 05666f8..141cf7a 100644 --- a/R3.DynamicData/List/Internal/ToObservableChangeSet.cs +++ b/R3Ext.DynamicData/List/Internal/ToObservableChangeSet.cs @@ -9,7 +9,7 @@ using System.Linq; using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class ToObservableChangeSet where TObject : notnull diff --git a/R3.DynamicData/List/Internal/Transform.cs b/R3Ext.DynamicData/List/Internal/Transform.cs similarity index 99% rename from R3.DynamicData/List/Internal/Transform.cs rename to R3Ext.DynamicData/List/Internal/Transform.cs index fab8419..56fc3fe 100644 --- a/R3.DynamicData/List/Internal/Transform.cs +++ b/R3Ext.DynamicData/List/Internal/Transform.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class Transform { diff --git a/R3.DynamicData/List/Internal/TransformMany.cs b/R3Ext.DynamicData/List/Internal/TransformMany.cs similarity index 99% rename from R3.DynamicData/List/Internal/TransformMany.cs rename to R3Ext.DynamicData/List/Internal/TransformMany.cs index be618df..e24d4bb 100644 --- a/R3.DynamicData/List/Internal/TransformMany.cs +++ b/R3Ext.DynamicData/List/Internal/TransformMany.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class TransformMany where TDestination : notnull diff --git a/R3.DynamicData/List/Internal/Virtualiser.cs b/R3Ext.DynamicData/List/Internal/Virtualiser.cs similarity index 98% rename from R3.DynamicData/List/Internal/Virtualiser.cs rename to R3Ext.DynamicData/List/Internal/Virtualiser.cs index 16e6f87..4637916 100644 --- a/R3.DynamicData/List/Internal/Virtualiser.cs +++ b/R3Ext.DynamicData/List/Internal/Virtualiser.cs @@ -1,6 +1,6 @@ using R3; -namespace R3.DynamicData.List.Internal; +namespace R3Ext.DynamicData.List.Internal; internal sealed class Virtualiser where T : notnull diff --git a/R3.DynamicData/List/ListChangeReason.cs b/R3Ext.DynamicData/List/ListChangeReason.cs similarity index 95% rename from R3.DynamicData/List/ListChangeReason.cs rename to R3Ext.DynamicData/List/ListChangeReason.cs index bf6a218..3cc5acf 100644 --- a/R3.DynamicData/List/ListChangeReason.cs +++ b/R3Ext.DynamicData/List/ListChangeReason.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// The reason for a change in a list. diff --git a/R3.DynamicData/List/ObservableListAggregates.cs b/R3Ext.DynamicData/List/ObservableListAggregates.cs similarity index 99% rename from R3.DynamicData/List/ObservableListAggregates.cs rename to R3Ext.DynamicData/List/ObservableListAggregates.cs index 1bb732d..a463d0f 100644 --- a/R3.DynamicData/List/ObservableListAggregates.cs +++ b/R3Ext.DynamicData/List/ObservableListAggregates.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.Utilities; +using R3Ext.DynamicData.Utilities; -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Aggregate operators for observable lists. diff --git a/R3.DynamicData/List/ObservableListEx.CastConvert.cs b/R3Ext.DynamicData/List/ObservableListEx.CastConvert.cs similarity index 96% rename from R3.DynamicData/List/ObservableListEx.CastConvert.cs rename to R3Ext.DynamicData/List/ObservableListEx.CastConvert.cs index 62ab3f8..784ac3c 100644 --- a/R3.DynamicData/List/ObservableListEx.CastConvert.cs +++ b/R3Ext.DynamicData/List/ObservableListEx.CastConvert.cs @@ -1,5 +1,5 @@ // Convenience Convert operator for list change sets. -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Extension methods for observable list change sets. diff --git a/R3.DynamicData/List/ObservableListEx.FilterOnObservable.cs b/R3Ext.DynamicData/List/ObservableListEx.FilterOnObservable.cs similarity index 99% rename from R3.DynamicData/List/ObservableListEx.FilterOnObservable.cs rename to R3Ext.DynamicData/List/ObservableListEx.FilterOnObservable.cs index e396695..9ac5b2f 100644 --- a/R3.DynamicData/List/ObservableListEx.FilterOnObservable.cs +++ b/R3Ext.DynamicData/List/ObservableListEx.FilterOnObservable.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Extension methods for observable list change sets. diff --git a/R3.DynamicData/List/ObservableListEx.LimitSizeTo.cs b/R3Ext.DynamicData/List/ObservableListEx.LimitSizeTo.cs similarity index 99% rename from R3.DynamicData/List/ObservableListEx.LimitSizeTo.cs rename to R3Ext.DynamicData/List/ObservableListEx.LimitSizeTo.cs index 96805e6..60a7106 100644 --- a/R3.DynamicData/List/ObservableListEx.LimitSizeTo.cs +++ b/R3Ext.DynamicData/List/ObservableListEx.LimitSizeTo.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Eviction policy for . diff --git a/R3.DynamicData/List/ObservableListEx.MergeChangeSets.cs b/R3Ext.DynamicData/List/ObservableListEx.MergeChangeSets.cs similarity index 99% rename from R3.DynamicData/List/ObservableListEx.MergeChangeSets.cs rename to R3Ext.DynamicData/List/ObservableListEx.MergeChangeSets.cs index 68ad927..02d62e5 100644 --- a/R3.DynamicData/List/ObservableListEx.MergeChangeSets.cs +++ b/R3Ext.DynamicData/List/ObservableListEx.MergeChangeSets.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Linq; -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Extension methods for observable list change sets. diff --git a/R3.DynamicData/List/ObservableListEx.TransformAsync.cs b/R3Ext.DynamicData/List/ObservableListEx.TransformAsync.cs similarity index 99% rename from R3.DynamicData/List/ObservableListEx.TransformAsync.cs rename to R3Ext.DynamicData/List/ObservableListEx.TransformAsync.cs index c7ce329..0cd45a2 100644 --- a/R3.DynamicData/List/ObservableListEx.TransformAsync.cs +++ b/R3Ext.DynamicData/List/ObservableListEx.TransformAsync.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Extension methods for observable list change sets. diff --git a/R3.DynamicData/List/ObservableListEx.Virtualize.cs b/R3Ext.DynamicData/List/ObservableListEx.Virtualize.cs similarity index 99% rename from R3.DynamicData/List/ObservableListEx.Virtualize.cs rename to R3Ext.DynamicData/List/ObservableListEx.Virtualize.cs index 803e5c9..a168912 100644 --- a/R3.DynamicData/List/ObservableListEx.Virtualize.cs +++ b/R3Ext.DynamicData/List/ObservableListEx.Virtualize.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.List; +using R3Ext.DynamicData.List; -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Extension methods for observable list change sets. diff --git a/R3.DynamicData/List/ObservableListEx.cs b/R3Ext.DynamicData/List/ObservableListEx.cs similarity index 99% rename from R3.DynamicData/List/ObservableListEx.cs rename to R3Ext.DynamicData/List/ObservableListEx.cs index 45311d9..9bc4f3b 100644 --- a/R3.DynamicData/List/ObservableListEx.cs +++ b/R3Ext.DynamicData/List/ObservableListEx.cs @@ -3,11 +3,11 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq.Expressions; -using R3.DynamicData.Binding; -using R3.DynamicData.Cache; -using R3.DynamicData.List.Internal; +using R3Ext.DynamicData.Binding; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.List.Internal; -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Extension methods for observable list change sets. @@ -22,7 +22,7 @@ public static partial class ObservableListEx /// The source observable change set. /// Function to transform each item. /// An observable that emits change sets with transformed items. - public static Observable> Transform( + public static Observable> Transform( this Observable> source, Func selector) { diff --git a/R3.DynamicData/List/SortOptions.cs b/R3Ext.DynamicData/List/SortOptions.cs similarity index 94% rename from R3.DynamicData/List/SortOptions.cs rename to R3Ext.DynamicData/List/SortOptions.cs index 75fc173..3ab8601 100644 --- a/R3.DynamicData/List/SortOptions.cs +++ b/R3Ext.DynamicData/List/SortOptions.cs @@ -4,7 +4,7 @@ using System; -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// Sort optimization options. diff --git a/R3.DynamicData/List/SourceList.cs b/R3Ext.DynamicData/List/SourceList.cs similarity index 99% rename from R3.DynamicData/List/SourceList.cs rename to R3Ext.DynamicData/List/SourceList.cs index d9a7e2d..3c15a33 100644 --- a/R3.DynamicData/List/SourceList.cs +++ b/R3Ext.DynamicData/List/SourceList.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.List; +namespace R3Ext.DynamicData.List; /// /// A reactive list implementation that publishes changes as an observable change set. diff --git a/R3.DynamicData/Operators/CombineOperator.cs b/R3Ext.DynamicData/Operators/CombineOperator.cs similarity index 98% rename from R3.DynamicData/Operators/CombineOperator.cs rename to R3Ext.DynamicData/Operators/CombineOperator.cs index 3abcc29..89eff79 100644 --- a/R3.DynamicData/Operators/CombineOperator.cs +++ b/R3Ext.DynamicData/Operators/CombineOperator.cs @@ -1,9 +1,9 @@ // Port of DynamicData to R3. -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; -namespace R3.DynamicData.Operators; +namespace R3Ext.DynamicData.Operators; /// /// Extension methods for combining multiple observable cache change sets. diff --git a/R3.DynamicData/Operators/FilterOperator.cs b/R3Ext.DynamicData/Operators/FilterOperator.cs similarity index 99% rename from R3.DynamicData/Operators/FilterOperator.cs rename to R3Ext.DynamicData/Operators/FilterOperator.cs index c5eb21b..4f13c20 100644 --- a/R3.DynamicData/Operators/FilterOperator.cs +++ b/R3Ext.DynamicData/Operators/FilterOperator.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.Cache; +using R3Ext.DynamicData.Cache; -namespace R3.DynamicData.Operators; +namespace R3Ext.DynamicData.Operators; /// /// Extension methods for filtering observable change sets. diff --git a/R3.DynamicData/Operators/TransformOperator.cs b/R3Ext.DynamicData/Operators/TransformOperator.cs similarity index 99% rename from R3.DynamicData/Operators/TransformOperator.cs rename to R3Ext.DynamicData/Operators/TransformOperator.cs index 3c030b4..4fa6338 100644 --- a/R3.DynamicData/Operators/TransformOperator.cs +++ b/R3Ext.DynamicData/Operators/TransformOperator.cs @@ -1,8 +1,8 @@ // Port of DynamicData to R3. -using R3.DynamicData.Cache; +using R3Ext.DynamicData.Cache; -namespace R3.DynamicData.Operators; +namespace R3Ext.DynamicData.Operators; /// /// Extension methods for transforming observable change sets. diff --git a/R3.DynamicData/R3.DynamicData.csproj b/R3Ext.DynamicData/R3Ext.DynamicData.csproj similarity index 72% rename from R3.DynamicData/R3.DynamicData.csproj rename to R3Ext.DynamicData/R3Ext.DynamicData.csproj index e7c9a84..a0808be 100644 --- a/R3.DynamicData/R3.DynamicData.csproj +++ b/R3Ext.DynamicData/R3Ext.DynamicData.csproj @@ -6,8 +6,10 @@ enable true true - R3.DynamicData - R3 Dynamic Data + R3Ext.DynamicData + R3Ext.DynamicData + R3Ext.DynamicData + R3Ext Dynamic Data DynamicData ported to R3 - Reactive collections for high-performance scenarios. API-compatible with DynamicData but powered by R3 for superior performance. reactive;rx;r3;dynamicdata;collections;observable;linq Copyright (c) 2025 @@ -18,6 +20,12 @@ + + + + diff --git a/R3.DynamicData/Utilities/RefBool.cs b/R3Ext.DynamicData/Utilities/RefBool.cs similarity index 96% rename from R3.DynamicData/Utilities/RefBool.cs rename to R3Ext.DynamicData/Utilities/RefBool.cs index 5e34ba9..8a4c403 100644 --- a/R3.DynamicData/Utilities/RefBool.cs +++ b/R3Ext.DynamicData/Utilities/RefBool.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Utilities; +namespace R3Ext.DynamicData.Utilities; /// /// A mutable boolean wrapper for use in closure-free state structs. diff --git a/R3.DynamicData/Utilities/RefInt.cs b/R3Ext.DynamicData/Utilities/RefInt.cs similarity index 97% rename from R3.DynamicData/Utilities/RefInt.cs rename to R3Ext.DynamicData/Utilities/RefInt.cs index 1d60c75..d5ec906 100644 --- a/R3.DynamicData/Utilities/RefInt.cs +++ b/R3Ext.DynamicData/Utilities/RefInt.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Utilities; +namespace R3Ext.DynamicData.Utilities; /// /// A mutable integer wrapper for use in closure-free state structs. diff --git a/R3.DynamicData/Utilities/RefValue.cs b/R3Ext.DynamicData/Utilities/RefValue.cs similarity index 95% rename from R3.DynamicData/Utilities/RefValue.cs rename to R3Ext.DynamicData/Utilities/RefValue.cs index 3c9c1bf..f64765a 100644 --- a/R3.DynamicData/Utilities/RefValue.cs +++ b/R3Ext.DynamicData/Utilities/RefValue.cs @@ -1,6 +1,6 @@ // Port of DynamicData to R3. -namespace R3.DynamicData.Utilities; +namespace R3Ext.DynamicData.Utilities; /// /// A mutable generic value wrapper for use in closure-free state structs. diff --git a/R3.DynamicData/Utilities/VirtualRequest.cs b/R3Ext.DynamicData/Utilities/VirtualRequest.cs similarity index 98% rename from R3.DynamicData/Utilities/VirtualRequest.cs rename to R3Ext.DynamicData/Utilities/VirtualRequest.cs index 03aa680..5a83221 100644 --- a/R3.DynamicData/Utilities/VirtualRequest.cs +++ b/R3Ext.DynamicData/Utilities/VirtualRequest.cs @@ -1,4 +1,4 @@ -namespace R3.DynamicData; +namespace R3Ext.DynamicData; /// /// Represents a request for a virtual page of data. diff --git a/R3Ext.SampleApp/Pages/DDAggregationPage.xaml.cs b/R3Ext.SampleApp/Pages/DDAggregationPage.xaml.cs index 594e7af..d36026c 100644 --- a/R3Ext.SampleApp/Pages/DDAggregationPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DDAggregationPage.xaml.cs @@ -3,7 +3,7 @@ using System.Linq; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/Pages/DDGroupingPage.xaml.cs b/R3Ext.SampleApp/Pages/DDGroupingPage.xaml.cs index e1cf127..449ba22 100644 --- a/R3Ext.SampleApp/Pages/DDGroupingPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DDGroupingPage.xaml.cs @@ -3,7 +3,7 @@ using System.Linq; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/Pages/DDLifecyclePage.xaml.cs b/R3Ext.SampleApp/Pages/DDLifecyclePage.xaml.cs index 44734d1..45f51e5 100644 --- a/R3Ext.SampleApp/Pages/DDLifecyclePage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DDLifecyclePage.xaml.cs @@ -4,7 +4,7 @@ using System.Text; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/Pages/DDLogicalOperatorsPage.xaml.cs b/R3Ext.SampleApp/Pages/DDLogicalOperatorsPage.xaml.cs index 57b4367..04bafe4 100644 --- a/R3Ext.SampleApp/Pages/DDLogicalOperatorsPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DDLogicalOperatorsPage.xaml.cs @@ -3,7 +3,7 @@ using System.Linq; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/Pages/DDRefreshPage.xaml.cs b/R3Ext.SampleApp/Pages/DDRefreshPage.xaml.cs index 5235c7d..b405cb9 100644 --- a/R3Ext.SampleApp/Pages/DDRefreshPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DDRefreshPage.xaml.cs @@ -5,7 +5,7 @@ using System.Runtime.CompilerServices; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/Pages/DDTransformationPage.xaml.cs b/R3Ext.SampleApp/Pages/DDTransformationPage.xaml.cs index b56672f..125b3fd 100644 --- a/R3Ext.SampleApp/Pages/DDTransformationPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DDTransformationPage.xaml.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; using R3Ext.SampleApp.ViewModels; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/Pages/DynamicDataBasicsPage.xaml.cs b/R3Ext.SampleApp/Pages/DynamicDataBasicsPage.xaml.cs index 04fb44b..e9743e5 100644 --- a/R3Ext.SampleApp/Pages/DynamicDataBasicsPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DynamicDataBasicsPage.xaml.cs @@ -4,7 +4,7 @@ using System.Linq; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/Pages/DynamicDataCachePage.xaml.cs b/R3Ext.SampleApp/Pages/DynamicDataCachePage.xaml.cs index 9bcca21..de648c9 100644 --- a/R3Ext.SampleApp/Pages/DynamicDataCachePage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DynamicDataCachePage.xaml.cs @@ -3,7 +3,7 @@ using System.Linq; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.Cache; +using R3Ext.DynamicData.Cache; using R3Ext.SampleApp.ViewModels; namespace R3Ext.SampleApp; @@ -141,9 +141,9 @@ private void OnStartWatching(object sender, EventArgs e) { var action = change.Reason switch { - R3.DynamicData.Kernel.ChangeReason.Add => "Added", - R3.DynamicData.Kernel.ChangeReason.Update => "Updated", - R3.DynamicData.Kernel.ChangeReason.Remove => "Removed", + R3Ext.DynamicData.Kernel.ChangeReason.Add => "Added", + R3Ext.DynamicData.Kernel.ChangeReason.Update => "Updated", + R3Ext.DynamicData.Kernel.ChangeReason.Remove => "Removed", _ => "Changed", }; this.WatchLabel.Text = $"ID {id} {action}: {change.Current.Name} ({change.Current.City})"; diff --git a/R3Ext.SampleApp/Pages/DynamicDataDistinctValuesPage.xaml.cs b/R3Ext.SampleApp/Pages/DynamicDataDistinctValuesPage.xaml.cs index b9e758b..c621728 100644 --- a/R3Ext.SampleApp/Pages/DynamicDataDistinctValuesPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DynamicDataDistinctValuesPage.xaml.cs @@ -4,8 +4,8 @@ using System.Linq; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.List; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.List; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/Pages/DynamicDataFilterSortPage.xaml.cs b/R3Ext.SampleApp/Pages/DynamicDataFilterSortPage.xaml.cs index 6ffd202..ae992f6 100644 --- a/R3Ext.SampleApp/Pages/DynamicDataFilterSortPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DynamicDataFilterSortPage.xaml.cs @@ -4,7 +4,7 @@ using System.Linq; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; using R3Ext.SampleApp.ViewModels; namespace R3Ext.SampleApp; @@ -46,7 +46,7 @@ public DynamicDataFilterSortPage() _comparerSubject.OnNext(NameAscComparer); // Prepare target collection - var target = new R3.DynamicData.Binding.ObservableCollectionExtended(); + var target = new R3Ext.DynamicData.Binding.ObservableCollectionExtended(); _people = new ReadOnlyObservableCollection(target); // Pipeline: AutoRefresh on Name and Age changes -> Filter -> Sort -> Bind diff --git a/R3Ext.SampleApp/Pages/DynamicDataOperatorsPage.xaml.cs b/R3Ext.SampleApp/Pages/DynamicDataOperatorsPage.xaml.cs index bdce567..0f386c2 100644 --- a/R3Ext.SampleApp/Pages/DynamicDataOperatorsPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DynamicDataOperatorsPage.xaml.cs @@ -4,7 +4,7 @@ using System.Linq; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; using R3Ext.SampleApp.ViewModels; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/Pages/DynamicDataTransformManyPage.xaml.cs b/R3Ext.SampleApp/Pages/DynamicDataTransformManyPage.xaml.cs index 6f8b6e7..19c83ce 100644 --- a/R3Ext.SampleApp/Pages/DynamicDataTransformManyPage.xaml.cs +++ b/R3Ext.SampleApp/Pages/DynamicDataTransformManyPage.xaml.cs @@ -4,8 +4,8 @@ using System.Linq; using Microsoft.Maui.Controls; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.List; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.List; using R3Ext.SampleApp.ViewModels; namespace R3Ext.SampleApp; diff --git a/R3Ext.SampleApp/R3Ext.SampleApp.csproj b/R3Ext.SampleApp/R3Ext.SampleApp.csproj index 081d34d..dc8b6dd 100644 --- a/R3Ext.SampleApp/R3Ext.SampleApp.csproj +++ b/R3Ext.SampleApp/R3Ext.SampleApp.csproj @@ -53,7 +53,7 @@ either BOTH runtimes must be indicated or ONLY macatalyst-x64. --> - + diff --git a/R3Ext.Tests/AggregateStreamTests.cs b/R3Ext.Tests/AggregateStreamTests.cs new file mode 100644 index 0000000..23da619 --- /dev/null +++ b/R3Ext.Tests/AggregateStreamTests.cs @@ -0,0 +1,107 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class AggregateStreamTests +{ + [Fact] + public void RunningCount_EmitsIncrementalCount() + { + Subject subject = new(); + LiveList result = subject.RunningCount().ToLiveList(); + subject.OnNext("a"); subject.OnNext("b"); subject.OnNext("c"); + subject.OnCompleted(); + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void RunningSum_EmitsRunningSumOfInts() + { + Subject subject = new(); + LiveList result = subject.RunningSum().ToLiveList(); + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnCompleted(); + Assert.Equal(new[] { 1, 3, 6 }, result.ToArray()); + } + + [Fact] + public void RunningMin_EmitsCurrentMinimum() + { + Subject subject = new(); + LiveList result = subject.RunningMin().ToLiveList(); + subject.OnNext(5); subject.OnNext(3); subject.OnNext(7); subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 5, 3, 3, 1 }, result.ToArray()); + } + + [Fact] + public void RunningMax_EmitsCurrentMaximum() + { + Subject subject = new(); + LiveList result = subject.RunningMax().ToLiveList(); + subject.OnNext(1); subject.OnNext(5); subject.OnNext(3); subject.OnNext(7); + subject.OnCompleted(); + Assert.Equal(new[] { 1, 5, 5, 7 }, result.ToArray()); + } + + [Fact] + public void RunningAverage_EmitsRunningAverageOfDoubles() + { + Subject subject = new(); + LiveList result = subject.RunningAverage().ToLiveList(); + subject.OnNext(2.0); subject.OnNext(4.0); subject.OnNext(6.0); + subject.OnCompleted(); + Assert.Equal(new[] { 2.0, 3.0, 4.0 }, result.ToArray()); + } + + [Fact] + public void RunningAverage_EmitsRunningAverageOfDecimals() + { + Subject subject = new(); + LiveList result = subject.RunningAverage().ToLiveList(); + subject.OnNext(2m); subject.OnNext(4m); subject.OnNext(6m); + subject.OnCompleted(); + Assert.Equal(new[] { 2m, 3m, 4m }, result.ToArray()); + } + + [Fact] + public void RunningAverage_EmitsRunningAverageOfInts() + { + Subject subject = new(); + LiveList result = subject.RunningAverage().ToLiveList(); + subject.OnNext(1); subject.OnNext(3); subject.OnNext(5); + subject.OnCompleted(); + Assert.Equal(new[] { 1.0, 2.0, 3.0 }, result.ToArray()); + } + + [Fact] + public void RunningMin_WithCustomComparer_EmitsCurrentMinimum() + { + Subject subject = new(); + LiveList result = subject.RunningMin(StringComparer.Ordinal).ToLiveList(); + subject.OnNext("banana"); subject.OnNext("apple"); subject.OnNext("cherry"); + subject.OnCompleted(); + Assert.Equal(new[] { "banana", "apple", "apple" }, result.ToArray()); + } + + [Fact] + public void RunningMax_WithCustomComparer_EmitsCurrentMaximum() + { + Subject subject = new(); + LiveList result = subject.RunningMax(StringComparer.Ordinal).ToLiveList(); + subject.OnNext("apple"); subject.OnNext("cherry"); subject.OnNext("banana"); + subject.OnCompleted(); + Assert.Equal(new[] { "apple", "cherry", "cherry" }, result.ToArray()); + } + + [Fact] + public void RunningCount_EmptySequence_EmitsNothing() + { + Subject subject = new(); + LiveList result = subject.RunningCount().ToLiveList(); + subject.OnCompleted(); + Assert.Empty(result.ToArray()); + } +} diff --git a/R3Ext.Tests/CacheFilterTests.cs b/R3Ext.Tests/CacheFilterTests.cs index 6cd3be0..52f2cfe 100644 --- a/R3Ext.Tests/CacheFilterTests.cs +++ b/R3Ext.Tests/CacheFilterTests.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Linq; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; -using R3.DynamicData.Operators; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.Operators; #pragma warning disable SA1208 #pragma warning disable SA1516 #pragma warning disable SA1501 diff --git a/R3Ext.Tests/CacheOperatorParityPhase2Tests.cs b/R3Ext.Tests/CacheOperatorParityPhase2Tests.cs index 41be180..eed8466 100644 --- a/R3Ext.Tests/CacheOperatorParityPhase2Tests.cs +++ b/R3Ext.Tests/CacheOperatorParityPhase2Tests.cs @@ -5,10 +5,10 @@ using System.Linq; using System.Threading.Tasks; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; -using R3.DynamicData.List; -using R3.DynamicData.Operators; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; +using R3Ext.DynamicData.Operators; using Xunit; namespace R3Ext.Tests; @@ -120,7 +120,7 @@ public async Task AddKey_ProducesKeyedChanges() var results = new List>(); var emitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Observable> keyed = list.Connect().AddKey(i => i.Id); + Observable> keyed = list.Connect().AddKey(i => i.Id); using var sub = keyed.QueryWhenChanged().Subscribe(x => { results.Add(x); diff --git a/R3Ext.Tests/ChangeKeyCacheTests.cs b/R3Ext.Tests/ChangeKeyCacheTests.cs index 8604066..508daf0 100644 --- a/R3Ext.Tests/ChangeKeyCacheTests.cs +++ b/R3Ext.Tests/ChangeKeyCacheTests.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Linq; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; #pragma warning disable SA1516, SA1503, SA1513, SA1107, SA1502, SA1515 namespace R3Ext.Tests; diff --git a/R3Ext.Tests/CombinationExtensionsTests.cs b/R3Ext.Tests/CombinationExtensionsTests.cs new file mode 100644 index 0000000..46d2d9f --- /dev/null +++ b/R3Ext.Tests/CombinationExtensionsTests.cs @@ -0,0 +1,424 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 +using System; +using System.Collections.Generic; +using R3; +using R3.Collections; +using Xunit; + +#pragma warning disable SA1503, SA1513, SA1515, SA1107, SA1502, SA1508, SA1516 + +namespace R3Ext.Tests; + +public class CombinationExtensionsTests +{ + #region ForkJoin + + [Fact] + public void ForkJoin_EmitsLastValuesWhenAllComplete() + { + Subject s1 = new(), s2 = new(), s3 = new(); + LiveList<(int, int, int)> result = CombinationExtensions.ForkJoin(s1, s2, s3).ToLiveList(); + + s1.OnNext(1); s1.OnNext(2); s1.OnCompleted(); + s2.OnNext(10); s2.OnCompleted(); + Assert.Empty(result.ToArray()); + + s3.OnNext(100); s3.OnCompleted(); + Assert.Single(result.ToArray()); + Assert.Equal((2, 10, 100), result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_EmptySourcesReturnsEmptyArray() + { + LiveList result = CombinationExtensions.ForkJoin().ToLiveList(); + Assert.Single(result.ToArray()); + Assert.Empty(result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_SingleSourceEmitsLastValue() + { + Subject s = new(); + LiveList result = CombinationExtensions.ForkJoin(s).ToLiveList(); + + s.OnNext(5); + s.OnNext(99); + s.OnCompleted(); + + Assert.Single(result.ToArray()); + Assert.Equal(new[] { 99 }, result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_SourceWithNoValueCompletesWithFailure() + { + Subject s1 = new(), s2 = new(); + bool completed = false; + + CombinationExtensions.ForkJoin(s1, s2).Subscribe( + _ => { }, + _ => { }, + r => { completed = true; Assert.True(r.IsFailure); }); + + s1.OnNext(1); + s1.OnCompleted(); + s2.OnCompleted(); // s2 never emitted + + Assert.True(completed); + } + + [Fact] + public void ForkJoin_Typed_TwoSources() + { + Subject s1 = new(); + Subject s2 = new(); + LiveList<(int, string)> result = CombinationExtensions.ForkJoin(s1, s2).ToLiveList(); + + s1.OnNext(42); s1.OnCompleted(); + s2.OnNext("hello"); s2.OnCompleted(); + + Assert.Single(result.ToArray()); + Assert.Equal((42, "hello"), result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_Typed_ThreeSources() + { + Subject s1 = new(); + Subject s2 = new(); + Subject s3 = new(); + LiveList<(int, string, bool)> result = CombinationExtensions.ForkJoin(s1, s2, s3).ToLiveList(); + + s1.OnNext(1); s1.OnCompleted(); + s2.OnNext("x"); s2.OnCompleted(); + s3.OnNext(true); s3.OnCompleted(); + + Assert.Single(result.ToArray()); + Assert.Equal((1, "x", true), result.ToArray()[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ForkJoin_NullSourcesThrows() + { + Assert.Throws(() => CombinationExtensions.ForkJoin((IEnumerable>)null!)); + } + + #endregion + + #region Generate + + [Fact] + public void Generate_ProducesSequence() + { + LiveList result = CombinationExtensions + .Generate(0, x => x < 5, x => x + 1, x => x * 2) + .ToLiveList(); + + Assert.Equal(new[] { 0, 2, 4, 6, 8 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Generate_EmptyWhenConditionFalseFromStart() + { + LiveList result = CombinationExtensions + .Generate(10, x => x < 5, x => x + 1, x => x) + .ToLiveList(); + + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Generate_SingleItem() + { + LiveList result = CombinationExtensions + .Generate(0, x => x < 1, x => x + 1, x => x + 100) + .ToLiveList(); + + Assert.Equal(new[] { 100 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Generate_StateOverload_ProducesSequence() + { + LiveList result = CombinationExtensions + .Generate(1, x => x <= 5, x => x + 1) + .ToLiveList(); + + Assert.Equal(new[] { 1, 2, 3, 4, 5 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Generate_NullConditionThrows() + { + Assert.Throws(() => + CombinationExtensions.Generate(0, null!, x => x + 1, x => x)); + } + + #endregion + + #region Iif / Condition + + [Fact] + public void Iif_SelectsBasedOnCondition() + { + bool flag = true; + LiveList result = CombinationExtensions + .Iif(() => flag, Observable.Return(1), Observable.Return(2)) + .ToLiveList(); + Assert.Equal(new[] { 1 }, result.ToArray()); + + flag = false; + LiveList result2 = CombinationExtensions + .Iif(() => flag, Observable.Return(1), Observable.Return(2)) + .ToLiveList(); + Assert.Equal(new[] { 2 }, result2.ToArray()); + } + + [Fact] + public void Iif_EvaluatesConditionAtSubscribeTime() + { + bool flag = true; + Observable obs = CombinationExtensions.Iif(() => flag, Observable.Return(10), Observable.Return(20)); + + flag = false; + LiveList result = obs.ToLiveList(); + + // Condition evaluated at subscribe time (when ToLiveList subscribes), flag is false + Assert.Equal(new[] { 20 }, result.ToArray()); + } + + [Fact] + public void Iif_NullConditionThrows() + { + Assert.Throws(() => + CombinationExtensions.Iif(null!, Observable.Return(1), Observable.Return(2))); + } + + [Fact] + public void Condition_IsAliasForIif() + { + bool flag = true; + LiveList result = CombinationExtensions + .Condition(() => flag, Observable.Return(42), Observable.Return(0)) + .ToLiveList(); + Assert.Equal(new[] { 42 }, result.ToArray()); + } + + #endregion + + #region SequenceEqual + + [Fact] + public void SequenceEqual_TrueForEqualSequences() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2).ToLiveList(); + + s1.OnNext(1); s2.OnNext(1); + s1.OnNext(2); s2.OnNext(2); + s1.OnCompleted(); s2.OnCompleted(); + + Assert.Equal(new[] { true }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_FalseOnMismatch() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2).ToLiveList(); + + s1.OnNext(1); s2.OnNext(99); + + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_FalseOnDifferentLengths() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2).ToLiveList(); + + s1.OnNext(1); + s1.OnCompleted(); + s2.OnNext(1); + s2.OnNext(2); + s2.OnCompleted(); + + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_TrueForBothEmpty() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2).ToLiveList(); + + s1.OnCompleted(); + s2.OnCompleted(); + + Assert.Equal(new[] { true }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_UsesCustomComparer() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.SequenceEqual(s2, StringComparer.OrdinalIgnoreCase).ToLiveList(); + + s1.OnNext("Hello"); s2.OnNext("hello"); + s1.OnCompleted(); s2.OnCompleted(); + + Assert.Equal(new[] { true }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SequenceEqual_NullSourceThrows() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.SequenceEqual(Observable.Return(1))); + } + + #endregion + + #region OnErrorResumeNext + + [Fact] + public void OnErrorResumeNext_ContinuesAfterError() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.OnErrorResumeNext(s2).ToLiveList(); + + s1.OnNext(1); + s1.OnErrorResume(new Exception()); + s2.OnNext(2); + s2.OnCompleted(); + + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void OnErrorResumeNext_ContinuesAfterSuccess() + { + Subject s1 = new(), s2 = new(); + LiveList result = s1.OnErrorResumeNext(s2).ToLiveList(); + + s1.OnNext(10); + s1.OnCompleted(); + s2.OnNext(20); + s2.OnCompleted(); + + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void OnErrorResumeNext_MultipleSources() + { + Subject s1 = new(), s2 = new(), s3 = new(); + LiveList result = CombinationExtensions.OnErrorResumeNext(s1, s2, s3).ToLiveList(); + + s1.OnNext(1); s1.OnCompleted(); + s2.OnNext(2); s2.OnErrorResume(new Exception()); + s3.OnNext(3); s3.OnCompleted(); + + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void OnErrorResumeNext_EmptySourcesCompletes() + { + LiveList result = CombinationExtensions.OnErrorResumeNext().ToLiveList(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void OnErrorResumeNext_NullSourceThrows() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.OnErrorResumeNext(Observable.Return(1))); + } + + #endregion + + #region RepeatWhen + + [Fact] + public void RepeatWhen_RepeatsWhenNotifierEmits() + { + int subscribeCount = 0; + Subject trigger = new(); + + Observable source = Observable.Create(observer => + { + subscribeCount++; + observer.OnNext(subscribeCount); + observer.OnCompleted(); + return Disposable.Empty; + }); + + LiveList result = source.RepeatWhen(_ => trigger).ToLiveList(); + + Assert.Equal(new[] { 1 }, result.ToArray()); + trigger.OnNext(Unit.Default); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + trigger.OnNext(Unit.Default); + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void RepeatWhen_StopsWhenHandlerCompletes() + { + Subject trigger = new(); + int subscribeCount = 0; + + Observable source = Observable.Create(observer => + { + subscribeCount++; + observer.OnNext(subscribeCount); + observer.OnCompleted(); + return Disposable.Empty; + }); + + LiveList result = source.RepeatWhen(_ => trigger).ToLiveList(); + + trigger.OnNext(Unit.Default); + trigger.OnCompleted(); + + Assert.True(result.IsCompleted); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + } + + [Fact] + public void RepeatWhen_NullSourceThrows() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.RepeatWhen(_ => Observable.Return(Unit.Default))); + } + + [Fact] + public void RepeatWhen_NullHandlerThrows() + { + Assert.Throws(() => + Observable.Return(1).RepeatWhen(null!)); + } + + #endregion +} diff --git a/R3Ext.Tests/DynamicDataSortTests.cs b/R3Ext.Tests/DynamicDataSortTests.cs index 742b766..c837e2d 100644 --- a/R3Ext.Tests/DynamicDataSortTests.cs +++ b/R3Ext.Tests/DynamicDataSortTests.cs @@ -3,8 +3,8 @@ using System.ComponentModel; using System.Linq; using R3; -using R3.DynamicData.Binding; -using R3.DynamicData.List; +using R3Ext.DynamicData.Binding; +using R3Ext.DynamicData.List; using Xunit; namespace R3Ext.Tests; diff --git a/R3Ext.Tests/EnsureUniqueKeysCacheTests.cs b/R3Ext.Tests/EnsureUniqueKeysCacheTests.cs index 1d1ca26..12da8ce 100644 --- a/R3Ext.Tests/EnsureUniqueKeysCacheTests.cs +++ b/R3Ext.Tests/EnsureUniqueKeysCacheTests.cs @@ -2,8 +2,8 @@ #pragma warning disable SA1516, SA1515, SA1503, SA1502, SA1107 using System.Linq; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; namespace R3Ext.Tests; diff --git a/R3Ext.Tests/ErrorHandlingExtraTests.cs b/R3Ext.Tests/ErrorHandlingExtraTests.cs new file mode 100644 index 0000000..228b182 --- /dev/null +++ b/R3Ext.Tests/ErrorHandlingExtraTests.cs @@ -0,0 +1,161 @@ +using R3; +using R3.Collections; + +#pragma warning disable SA1503, SA1513, SA1515, SA1107, SA1502, SA1508, SA1516 + +namespace R3Ext.Tests; + +public class ErrorHandlingExtraTests +{ + [Fact] + public void ReplaceError_EmitsFallbackOnErrorResume() + { + Subject subject = new(); + LiveList result = subject.ReplaceError(99).ToLiveList(); + subject.OnNext(1); + subject.OnErrorResume(new Exception("boom")); + Assert.Equal(new[] { 1, 99 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ReplaceError_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.ReplaceError(0)); + } + + [Fact] + public void ReplaceError_ForwardsSuccessCompletion() + { + Subject subject = new(); + LiveList result = subject.ReplaceError(99).ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 1 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ReplaceEmpty_EmitsFallbackWhenNoValues() + { + Subject subject = new(); + LiveList result = subject.ReplaceEmpty(42).ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { 42 }, result.ToArray()); + } + + [Fact] + public void ReplaceEmpty_DoesNotEmitFallbackWhenHasValues() + { + Subject subject = new(); + LiveList result = subject.ReplaceEmpty(42).ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 1 }, result.ToArray()); + } + + [Fact] + public void ReplaceEmpty_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.ReplaceEmpty(0)); + } + + [Fact] + public void SelectSafe_RoutesExceptionToOnErrorResume() + { + Subject subject = new(); + List errors = new(); + LiveList result = subject.SelectSafe(x => + { + if (x == 0) throw new DivideByZeroException(); + return x.ToString(); + }).DoOnError(ex => errors.Add(ex)).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(0); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Equal(new[] { "1", "2" }, result.ToArray()); + Assert.Single(errors); + Assert.IsType(errors[0]); + } + + [Fact] + public void SelectSafe_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.SelectSafe(x => x.ToString())); + } + + [Fact] + public void WhereSafe_RoutesExceptionToOnErrorResume() + { + Subject subject = new(); + List errors = new(); + LiveList result = subject.WhereSafe(x => + { + if (x < 0) throw new ArgumentOutOfRangeException(); + return x % 2 == 0; + }).DoOnError(ex => errors.Add(ex)).ToLiveList(); + + subject.OnNext(2); + subject.OnNext(-1); + subject.OnNext(4); + subject.OnCompleted(); + Assert.Equal(new[] { 2, 4 }, result.ToArray()); + Assert.Single(errors); + Assert.IsType(errors[0]); + } + + [Fact] + public void RetryWhen_RetriesOnHandlerSignal() + { + int attempts = 0; + // Use Observable.Create so events fire after subscription (not before like with pre-signaled Subject) + Observable src = Observable.Create(observer => + { + attempts++; + if (attempts < 3) + { + observer.OnErrorResume(new Exception("fail")); + } + else + { + observer.OnNext(42); + observer.OnCompleted(); + } + return Disposable.Empty; + }); + + LiveList result = src.RetryWhen(errors => errors.Select(_ => Unit.Default)).ToLiveList(); + Assert.Equal(new[] { 42 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void RetryWhen_CompletesWhenHandlerCompletes() + { + Subject subject = new(); + Subject trigger = new(); + LiveList result = subject.RetryWhen(_ => trigger).ToLiveList(); + subject.OnNext(1); + trigger.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void RetryWhen_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.RetryWhen(_ => Observable.Empty())); + } + + [Fact] + public void RetryWhen_NullHandler_ThrowsArgumentNullException() + { + Subject subject = new(); + Assert.Throws(() => subject.RetryWhen(null!)); + } +} diff --git a/R3Ext.Tests/FilteringAdvancedTests.cs b/R3Ext.Tests/FilteringAdvancedTests.cs new file mode 100644 index 0000000..647ab4b --- /dev/null +++ b/R3Ext.Tests/FilteringAdvancedTests.cs @@ -0,0 +1,519 @@ +using Microsoft.Extensions.Time.Testing; +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class FilteringAdvancedTests +{ + // โ”€โ”€โ”€ IgnoreElements โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void IgnoreElements_SuppressesOnNext() + { + Subject subject = new(); + LiveList result = subject.IgnoreElements().ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + Assert.Empty(result.ToArray()); + subject.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IgnoreElements_ForwardsError() + { + Subject subject = new(); + List errors = new(); + using IDisposable _ = subject.IgnoreElements().Subscribe(_ => { }, errors.Add, _ => { }); + subject.OnErrorResume(new InvalidOperationException("boom")); + Assert.Single(errors); + Assert.IsType(errors[0]); + } + + [Fact] + public void IgnoreElements_ForwardsCompletion() + { + Subject subject = new(); + LiveList result = subject.IgnoreElements().ToLiveList(); + subject.OnNext(42); + Assert.Empty(result.ToArray()); + subject.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IgnoreElements_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.IgnoreElements()); + } + + // โ”€โ”€โ”€ IsEmpty โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void IsEmpty_TrueWhenNoElements() + { + Subject subject = new(); + LiveList result = subject.IsEmpty().ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { true }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IsEmpty_FalseOnFirstElement() + { + Subject subject = new(); + LiveList result = subject.IsEmpty().ToLiveList(); + subject.OnNext(42); + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IsEmpty_CompletesAfterFirstElement_IgnoresSubsequent() + { + Subject subject = new(); + LiveList result = subject.IsEmpty().ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void IsEmpty_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.IsEmpty()); + } + + // โ”€โ”€โ”€ Every / All โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void Every_FalseOnFirstFail() + { + Subject subject = new(); + LiveList result = subject.Every(x => x > 0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(-1); + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Every_TrueOnCompletion() + { + Subject subject = new(); + LiveList result = subject.Every(x => x > 0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Equal(new[] { true }, result.ToArray()); + } + + [Fact] + public void Every_TrueWhenEmpty() + { + Subject subject = new(); + LiveList result = subject.Every(x => x > 0).ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { true }, result.ToArray()); + } + + [Fact] + public void Every_FalseOnVeryFirstValue() + { + Subject subject = new(); + LiveList result = subject.Every(x => x > 0).ToLiveList(); + subject.OnNext(-5); + Assert.Equal(new[] { false }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void All_IsAliasForEvery() + { + Subject subject = new(); + LiveList result = subject.All(x => x > 0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Equal(new[] { true }, result.ToArray()); + } + + [Fact] + public void Every_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.Every(x => x > 0)); + } + + [Fact] + public void Every_ThrowsOnNullPredicate() + { + Subject subject = new(); + Assert.Throws(() => subject.Every(null!)); + } + + // โ”€โ”€โ”€ Find โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void Find_EmitsFirstMatchAndCompletes() + { + Subject subject = new(); + LiveList result = subject.Find(x => x > 5).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(10); + subject.OnNext(20); + Assert.Equal(new[] { 10 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Find_CompletesWithoutEmitWhenNoMatch() + { + Subject subject = new(); + LiveList result = subject.Find(x => x > 100).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Find_EmitsFirstMatchOnly() + { + Subject subject = new(); + LiveList result = subject.Find(x => x % 2 == 0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(4); + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + [Fact] + public void Find_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.Find(x => x > 0)); + } + + // โ”€โ”€โ”€ FindIndex โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void FindIndex_EmitsCorrectIndex() + { + Subject subject = new(); + LiveList result = subject.FindIndex(x => x > 5).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(10); + Assert.Equal(new[] { 2 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void FindIndex_EmitsZeroForFirstElement() + { + Subject subject = new(); + LiveList result = subject.FindIndex(x => x > 0).ToLiveList(); + subject.OnNext(99); + Assert.Equal(new[] { 0 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void FindIndex_CompletesWithoutEmitWhenNoMatch() + { + Subject subject = new(); + LiveList result = subject.FindIndex(x => x > 100).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void FindIndex_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.FindIndex(x => x > 0)); + } + + // โ”€โ”€โ”€ DefaultIfEmpty โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void DefaultIfEmpty_EmitsDefaultWhenEmpty() + { + Subject subject = new(); + LiveList result = subject.DefaultIfEmpty(99).ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { 99 }, result.ToArray()); + } + + [Fact] + public void DefaultIfEmpty_DoesNotEmitDefaultWhenHasValues() + { + Subject subject = new(); + LiveList result = subject.DefaultIfEmpty(99).ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 1 }, result.ToArray()); + } + + [Fact] + public void DefaultIfEmpty_ForwardsAllValuesWhenPresent() + { + Subject subject = new(); + LiveList result = subject.DefaultIfEmpty(0).ToLiveList(); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnCompleted(); + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void DefaultIfEmpty_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.DefaultIfEmpty(0)); + } + + // โ”€โ”€โ”€ ThrowIfEmpty โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void ThrowIfEmpty_ThrowsDefaultExceptionWhenEmpty() + { + Subject subject = new(); + List completions = new(); + using IDisposable _ = subject.ThrowIfEmpty().Subscribe(_ => { }, _ => { }, completions.Add); + subject.OnCompleted(); + Assert.Single(completions); + Assert.True(completions[0].IsFailure); + Assert.IsType(completions[0].Exception); + } + + [Fact] + public void ThrowIfEmpty_UsesCustomExceptionFactory() + { + Subject subject = new(); + List completions = new(); + using IDisposable _ = subject + .ThrowIfEmpty(() => new ArgumentException("custom")) + .Subscribe(_ => { }, _ => { }, completions.Add); + subject.OnCompleted(); + Assert.Single(completions); + Assert.True(completions[0].IsFailure); + Assert.IsType(completions[0].Exception); + } + + [Fact] + public void ThrowIfEmpty_DoesNotThrowWhenHasValues() + { + Subject subject = new(); + LiveList result = subject.ThrowIfEmpty().ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(new[] { 1 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ThrowIfEmpty_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.ThrowIfEmpty()); + } + + // โ”€โ”€โ”€ Audit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void Audit_EmitsLatestWhenDurationFires() + { + Subject source = new(); + Subject durationSubject = new(); + LiveList result = source.Audit(_ => durationSubject).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + durationSubject.OnNext(Unit.Default); + + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + [Fact] + public void Audit_EmitsNothingIfNoDurationFire() + { + Subject source = new(); + Subject duration = new(); + LiveList result = source.Audit(_ => duration).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + source.OnCompleted(); + + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Audit_ResetsAfterDurationFiresAndAcceptsNewValue() + { + Subject source = new(); + Subject duration1 = new(); + Subject duration2 = new(); + int call = 0; + Subject[] durations = [duration1, duration2]; + + LiveList result = source.Audit(_ => durations[call++ % 2]).ToLiveList(); + + source.OnNext(10); + duration1.OnNext(Unit.Default); + Assert.Equal(new[] { 10 }, result.ToArray()); + + source.OnNext(20); + duration2.OnNext(Unit.Default); + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + } + + [Fact] + public void Audit_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.Audit(_ => Observable.Never())); + } + + // โ”€โ”€โ”€ AuditTime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void AuditTime_EmitsLatestAfterDuration() + { + FakeTimeProvider tp = new(); + Subject source = new(); + LiveList result = source.AuditTime(TimeSpan.FromSeconds(1), tp).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(1)); + + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + [Fact] + public void AuditTime_DoesNotEmitBeforeDuration() + { + FakeTimeProvider tp = new(); + Subject source = new(); + LiveList result = source.AuditTime(TimeSpan.FromSeconds(5), tp).ToLiveList(); + + source.OnNext(42); + tp.Advance(TimeSpan.FromSeconds(4)); + + Assert.Empty(result.ToArray()); + } + + [Fact] + public void AuditTime_CompletesWhenSourceCompletes() + { + FakeTimeProvider tp = new(); + Subject source = new(); + LiveList result = source.AuditTime(TimeSpan.FromSeconds(1), tp).ToLiveList(); + + source.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AuditTime_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.AuditTime(TimeSpan.FromSeconds(1))); + } + + // โ”€โ”€โ”€ Sample โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void Sample_EmitsLatestWhenSamplerFires() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + sampler.OnNext(Unit.Default); + + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + [Fact] + public void Sample_EmitsNothingIfNoSourceValue() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + sampler.OnNext(Unit.Default); + sampler.OnNext(Unit.Default); + + Assert.Empty(result.ToArray()); + } + + [Fact] + public void Sample_ResetsAfterEachSample() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + source.OnNext(10); + sampler.OnNext(Unit.Default); + Assert.Equal(new[] { 10 }, result.ToArray()); + + sampler.OnNext(Unit.Default); // no new value since last sample + Assert.Equal(new[] { 10 }, result.ToArray()); // nothing new emitted + + source.OnNext(20); + sampler.OnNext(Unit.Default); + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + } + + [Fact] + public void Sample_WithTypedSampler_EmitsLatest() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + source.OnNext(5); + sampler.OnNext("tick"); + + Assert.Equal(new[] { 5 }, result.ToArray()); + } + + [Fact] + public void Sample_CompletesWhenSourceCompletes() + { + Subject source = new(); + Subject sampler = new(); + LiveList result = source.Sample(sampler).ToLiveList(); + + source.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Sample_ThrowsOnNullSource() + { + Observable? nullSource = null; + Assert.Throws(() => nullSource!.Sample(new Subject())); + } +} diff --git a/R3Ext.Tests/FlatMapExtensionsTests.cs b/R3Ext.Tests/FlatMapExtensionsTests.cs new file mode 100644 index 0000000..7fc5d47 --- /dev/null +++ b/R3Ext.Tests/FlatMapExtensionsTests.cs @@ -0,0 +1,430 @@ +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class FlatMapExtensionsTests +{ + // โ”€โ”€ ConcatMap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void ConcatMap_ProcessesSequentially() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.ConcatMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); // queued; inner1 not yet done + + inner1.OnNext("a"); + Assert.Equal(new[] { "a" }, result.ToArray()); // inner2 not started yet + + inner1.OnCompleted(); + inner2.OnNext("b"); + Assert.Equal(new[] { "a", "b" }, result.ToArray()); + } + + [Fact] + public void ConcatMap_EmitsInOrder() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.ConcatMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + inner1.OnNext(10); + inner1.OnNext(11); + inner1.OnCompleted(); + inner2.OnNext(20); + inner2.OnCompleted(); + subject.OnCompleted(); + + Assert.Equal(new[] { 10, 11, 20 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ConcatMap_CompletionPropagatesAfterAllInners() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.ConcatMap(_ => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); // inner still active + + inner.OnNext("a"); + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ConcatMap_CompletesImmediatelyWhenNoInners() + { + Subject subject = new(); + LiveList result = subject.ConcatMap(x => Observable.Return(x)).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void ConcatMap_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.ConcatMap(x => Observable.Return(x))); + } + + [Fact] + public void ConcatMap_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.ConcatMap(null!)); + } + + // โ”€โ”€ SwitchMap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void SwitchMap_CancelsOnNewValue() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.SwitchMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + inner1.OnNext("a"); + subject.OnNext(2); // cancels inner1 + inner1.OnNext("dropped"); + inner2.OnNext("b"); + + Assert.Equal(new[] { "a", "b" }, result.ToArray()); + } + + [Fact] + public void SwitchMap_CompletesWhenSourceAndCurrentInnerDone() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.SwitchMap(_ => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); // inner still active + + inner.OnNext("a"); + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SwitchMap_CompletesImmediatelyWhenSourceCompletesWithNoActiveInner() + { + Subject subject = new(); + LiveList result = subject.SwitchMap(x => Observable.Return(x)).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void SwitchMap_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.SwitchMap(x => Observable.Return(x))); + } + + [Fact] + public void SwitchMap_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.SwitchMap(null!)); + } + + // โ”€โ”€ FlatMapLatest โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void FlatMapLatest_IsAliasForSwitchMap() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.FlatMapLatest(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + inner1.OnNext("a"); + subject.OnNext(2); + inner1.OnNext("dropped"); + inner2.OnNext("b"); + + Assert.Equal(new[] { "a", "b" }, result.ToArray()); + } + + // โ”€โ”€ ExhaustMap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void ExhaustMap_IgnoresValuesWhileInnerActive() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.ExhaustMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); // ignored; inner1 still active + inner1.OnNext("a"); + inner1.OnCompleted(); + + subject.OnNext(2); // now accepted + inner2.OnNext("b"); + + Assert.Equal(new[] { "a", "b" }, result.ToArray()); + } + + [Fact] + public void ExhaustMap_CompletesAfterSourceAndInner() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.ExhaustMap(_ => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); + + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ExhaustMap_AcceptsNextValueAfterInnerCompletes() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + + LiveList result = subject.ExhaustMap(x => x == 1 ? inner1 : inner2).ToLiveList(); + + subject.OnNext(1); + inner1.OnNext(10); + inner1.OnCompleted(); + subject.OnNext(2); + inner2.OnNext(20); + inner2.OnCompleted(); + subject.OnCompleted(); + + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ExhaustMap_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.ExhaustMap(x => Observable.Return(x))); + } + + [Fact] + public void ExhaustMap_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.ExhaustMap(null!)); + } + + // โ”€โ”€ Expand โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void Expand_RecursivelyAppliesSelector() + { + Subject subject = new(); + + // Expand values < 3 by adding 1; stop at 3 + LiveList result = subject + .Expand(x => x < 3 ? Observable.Return(x + 1) : Observable.Empty()) + .ToLiveList(); + + subject.OnNext(0); + subject.OnCompleted(); + + Assert.Equal(new[] { 0, 1, 2, 3 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Expand_CompletesWhenNoMoreExpansions() + { + Subject subject = new(); + + LiveList result = subject.Expand(_ => Observable.Empty()).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.Equal(new[] { 1 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Expand_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.Expand(x => Observable.Return(x))); + } + + [Fact] + public void Expand_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.Expand(null!)); + } + + // โ”€โ”€ MergeScan โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void MergeScan_UpdatesAccumulatorState() + { + Subject subject = new(); + + LiveList result = subject + .MergeScan(0, (acc, x) => Observable.Return(acc + x)) + .ToLiveList(); + + subject.OnNext(1); // acc=0 โ†’ Return(1) โ†’ current=1, emit 1 + subject.OnNext(2); // acc=1 โ†’ Return(3) โ†’ current=3, emit 3 + subject.OnNext(3); // acc=3 โ†’ Return(6) โ†’ current=6, emit 6 + + Assert.Equal(new[] { 1, 3, 6 }, result.ToArray()); + } + + [Fact] + public void MergeScan_CompletesWhenSourceAndAllInnersDone() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.MergeScan(0, (_, _) => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); + + inner.OnNext(10); + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void MergeScan_EmitsFromActiveInner() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.MergeScan(0, (acc, x) => inner).ToLiveList(); + + subject.OnNext(1); + inner.OnNext(42); + + Assert.Equal(new[] { 42 }, result.ToArray()); + } + + [Fact] + public void MergeScan_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.MergeScan(0, (acc, x) => Observable.Return(acc + x))); + } + + [Fact] + public void MergeScan_ThrowsOnNullAccumulator() + { + Subject subject = new(); + Assert.Throws(() => subject.MergeScan(0, null!)); + } + + // โ”€โ”€ SwitchScan โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void SwitchScan_SwitchesToLatestInner() + { + Subject subject = new(); + Subject inner1 = new(); + Subject inner2 = new(); + int callCount = 0; + + LiveList result = subject.SwitchScan(0, (acc, x) => + { + callCount++; + return callCount == 1 ? inner1 : inner2; + }).ToLiveList(); + + subject.OnNext(1); + inner1.OnNext(10); + subject.OnNext(2); // switches away from inner1 + inner1.OnNext(99); // dropped + inner2.OnNext(20); + + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + } + + [Fact] + public void SwitchScan_UpdatesAccumulatorOnEachSwitch() + { + Subject subject = new(); + + LiveList result = subject + .SwitchScan(0, (acc, x) => Observable.Return(acc + x)) + .ToLiveList(); + + subject.OnNext(1); // acc=0 โ†’ Return(1) โ†’ current=1, emit 1 + subject.OnNext(2); // acc=1 โ†’ Return(3) โ†’ current=3, emit 3 + subject.OnNext(4); // acc=3 โ†’ Return(7) โ†’ current=7, emit 7 + + Assert.Equal(new[] { 1, 3, 7 }, result.ToArray()); + } + + [Fact] + public void SwitchScan_CompletesWhenSourceAndCurrentInnerDone() + { + Subject subject = new(); + Subject inner = new(); + + LiveList result = subject.SwitchScan(0, (_, _) => inner).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + Assert.False(result.IsCompleted); + + inner.OnCompleted(); + Assert.True(result.IsCompleted); + } + + [Fact] + public void SwitchScan_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.SwitchScan(0, (acc, x) => Observable.Return(acc + x))); + } + + [Fact] + public void SwitchScan_ThrowsOnNullAccumulator() + { + Subject subject = new(); + Assert.Throws(() => subject.SwitchScan(0, null!)); + } +} diff --git a/R3Ext.Tests/GroupingTests.cs b/R3Ext.Tests/GroupingTests.cs index f7a655d..b9ac985 100644 --- a/R3Ext.Tests/GroupingTests.cs +++ b/R3Ext.Tests/GroupingTests.cs @@ -3,8 +3,8 @@ using System.ComponentModel; using System.Linq; using R3; // R3 observable extensions -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; #pragma warning disable SA1208 #pragma warning disable SA1516 #pragma warning disable SA1501 diff --git a/R3Ext.Tests/JoinTests.cs b/R3Ext.Tests/JoinTests.cs index 1c73340..ea58259 100644 --- a/R3Ext.Tests/JoinTests.cs +++ b/R3Ext.Tests/JoinTests.cs @@ -3,9 +3,9 @@ using System.ComponentModel; using System.Linq; using R3; // R3 observable extensions -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; -using R3.DynamicData.List; // For ListChangeReason if needed later +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; // For ListChangeReason if needed later #pragma warning disable SA1208 #pragma warning disable SA1516 #pragma warning disable SA1501 diff --git a/R3Ext.Tests/LimitSizeToTests.cs b/R3Ext.Tests/LimitSizeToTests.cs index bae7dd1..46da915 100644 --- a/R3Ext.Tests/LimitSizeToTests.cs +++ b/R3Ext.Tests/LimitSizeToTests.cs @@ -1,5 +1,5 @@ using R3; -using R3.DynamicData.List; +using R3Ext.DynamicData.List; using Xunit; namespace R3Ext.Tests; diff --git a/R3Ext.Tests/R3Ext.Tests.csproj b/R3Ext.Tests/R3Ext.Tests.csproj index 82330b0..9a3ed44 100644 --- a/R3Ext.Tests/R3Ext.Tests.csproj +++ b/R3Ext.Tests/R3Ext.Tests.csproj @@ -27,7 +27,7 @@ - + diff --git a/R3Ext.Tests/RemoveIndexTests.cs b/R3Ext.Tests/RemoveIndexTests.cs index 76f7e83..13ab9ed 100644 --- a/R3Ext.Tests/RemoveIndexTests.cs +++ b/R3Ext.Tests/RemoveIndexTests.cs @@ -1,5 +1,5 @@ using R3; // For Observable extensions -using R3.DynamicData.List; +using R3Ext.DynamicData.List; using Xunit; namespace R3Ext.Tests; diff --git a/R3Ext.Tests/ReverseOperatorTests.cs b/R3Ext.Tests/ReverseOperatorTests.cs index f5709e0..01d6949 100644 --- a/R3Ext.Tests/ReverseOperatorTests.cs +++ b/R3Ext.Tests/ReverseOperatorTests.cs @@ -1,5 +1,5 @@ using R3; // for Subscribe overloads -using R3.DynamicData.List; +using R3Ext.DynamicData.List; using Xunit; namespace R3Ext.Tests; diff --git a/R3Ext.Tests/SideEffectExtensionsTests.cs b/R3Ext.Tests/SideEffectExtensionsTests.cs new file mode 100644 index 0000000..0837256 --- /dev/null +++ b/R3Ext.Tests/SideEffectExtensionsTests.cs @@ -0,0 +1,154 @@ +using R3; +using R3.Collections; + +#pragma warning disable SA1503, SA1513, SA1515, SA1107, SA1502, SA1508, SA1516 + +namespace R3Ext.Tests; + +public class SideEffectExtensionsTests +{ + [Fact] + public void DoOnError_InvokesActionOnErrorResume() + { + Subject subject = new(); + List captured = new(); + LiveList result = subject.DoOnError(ex => captured.Add(ex)).ToLiveList(); + subject.OnNext(1); + subject.OnErrorResume(new InvalidOperationException("test")); + subject.OnNext(2); + subject.OnCompleted(); + Assert.Single(captured); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + } + + [Fact] + public void DoOnError_DoesNotFireOnSuccessCompletion() + { + Subject subject = new(); + int callCount = 0; + LiveList result = subject.DoOnError(_ => callCount++).ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + Assert.Equal(0, callCount); + Assert.Equal(new[] { 1 }, result.ToArray()); + } + + [Fact] + public void DoOnError_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.DoOnError(_ => { })); + } + + [Fact] + public void DoOnComplete_ResultOverload_InvokesOnTermination() + { + Subject subject = new(); + bool called = false; + subject.DoOnComplete(_ => called = true).ToLiveList(); + subject.OnCompleted(); + Assert.True(called); + } + + [Fact] + public void DoOnComplete_ActionOverload_InvokesOnlyOnSuccess() + { + Subject s1 = new(); + Subject s2 = new(); + int successCount = 0; + s1.DoOnComplete(() => successCount++).ToLiveList(); + s2.DoOnComplete(() => successCount++).ToLiveList(); + s1.OnCompleted(); + s2.OnCompleted(Result.Failure(new Exception("fail"))); + Assert.Equal(1, successCount); + } + + [Fact] + public void DoOnComplete_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.DoOnComplete(_ => { })); + Assert.Throws(() => nullSource.DoOnComplete(() => { })); + } + + [Fact] + public void DoOnTerminate_InvokesOnSuccessAndFailure() + { + int count = 0; + Subject s1 = new(); + Subject s2 = new(); + s1.DoOnTerminate(() => count++).ToLiveList(); + s2.DoOnTerminate(() => count++).ToLiveList(); + s1.OnCompleted(); + s2.OnCompleted(Result.Failure(new Exception("fail"))); + Assert.Equal(2, count); + } + + [Fact] + public void DoOnTerminate_DoesNotFireOnOnErrorResume() + { + Subject subject = new(); + int count = 0; + subject.DoOnTerminate(() => count++).ToLiveList(); + subject.OnErrorResume(new Exception("non-terminal")); + Assert.Equal(0, count); + } + + [Fact] + public void DoOnTerminate_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.DoOnTerminate(() => { })); + } + + [Fact] + public void DoAfterTerminate_FiresAfterDownstream() + { + Subject subject = new(); + List order = new(); + LiveList result = subject + .Do(onCompleted: _ => order.Add("downstream")) + .DoAfterTerminate(() => order.Add("after")) + .ToLiveList(); + subject.OnCompleted(); + Assert.Equal(new[] { "downstream", "after" }, order.ToArray()); + } + + [Fact] + public void DoAfterTerminate_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.DoAfterTerminate(() => { })); + } + + [Fact] + public void WithIndex_PairsValueWithIndex() + { + Subject subject = new(); + LiveList<(string Value, int Index)> result = subject.WithIndex().ToLiveList(); + subject.OnNext("a"); + subject.OnNext("b"); + subject.OnNext("c"); + subject.OnCompleted(); + Assert.Equal(("a", 0), result[0]); + Assert.Equal(("b", 1), result[1]); + Assert.Equal(("c", 2), result[2]); + } + + [Fact] + public void WithIndex_StartsAtZero() + { + Subject subject = new(); + LiveList<(int Value, int Index)> result = subject.WithIndex().ToLiveList(); + subject.OnNext(100); + subject.OnCompleted(); + Assert.Equal((100, 0), result[0]); + } + + [Fact] + public void WithIndex_NullSource_ThrowsArgumentNullException() + { + Observable nullSource = null!; + Assert.Throws(() => nullSource.WithIndex()); + } +} diff --git a/R3Ext.Tests/SubjectTypesTests.cs b/R3Ext.Tests/SubjectTypesTests.cs new file mode 100644 index 0000000..fcd0fe2 --- /dev/null +++ b/R3Ext.Tests/SubjectTypesTests.cs @@ -0,0 +1,108 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class SubjectTypesTests +{ + [Fact] + public void AsyncSubject_EmitsLastValueOnCompletion() + { + AsyncSubject subject = new(); + LiveList result = subject.ToLiveList(); + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnCompleted(); + Assert.Equal(new[] { 3 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AsyncSubject_LateSubscriberGetsLastValue() + { + AsyncSubject subject = new(); + subject.OnNext(42); + subject.OnCompleted(); + // subscribe AFTER completion + LiveList result = subject.ToLiveList(); + Assert.Equal(new[] { 42 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AsyncSubject_NoValueOnFailure() + { + AsyncSubject subject = new(); + subject.OnNext(1); + subject.OnCompleted(new Exception("fail")); + LiveList result = subject.ToLiveList(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AsyncSubject_EmptyOnCompletionWithNoValues() + { + AsyncSubject subject = new(); + LiveList result = subject.ToLiveList(); + subject.OnCompleted(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void AsyncSubject_OnNextAfterCompletion_IsIgnored() + { + AsyncSubject subject = new(); + LiveList result = subject.ToLiveList(); + subject.OnNext(1); + subject.OnCompleted(); + subject.OnNext(99); // should be ignored + Assert.Equal(new[] { 1 }, result.ToArray()); + } + + [Fact] + public void AsyncSubject_DisposedSubject_LateSubscriberGetsError() + { + AsyncSubject subject = new(); + subject.OnNext(5); + subject.Dispose(); + LiveList result = subject.ToLiveList(); + Assert.Empty(result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void ReadOnlySubject_HidesSubjectMethods() + { + Subject inner = new(); + ReadOnlySubject ro = inner.AsReadOnly(); + LiveList result = ro.ToLiveList(); + inner.OnNext(1); inner.OnNext(2); + inner.OnCompleted(); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + Assert.IsNotType>(ro); + } + + [Fact] + public void ReadOnlySubject_BehaviorSubject_EmitsCurrentAndFutureValues() + { + BehaviorSubject inner = new(10); + ReadOnlySubject ro = inner.AsReadOnly(); + LiveList result = ro.ToLiveList(); + inner.OnNext(20); + inner.OnCompleted(); + Assert.Equal(new[] { 10, 20 }, result.ToArray()); + } + + [Fact] + public void ReadOnlySubject_Observable_WrapsCorrectly() + { + Subject inner = new(); + ReadOnlySubject ro = ((Observable)inner).AsReadOnly(); + LiveList result = ro.ToLiveList(); + inner.OnNext("hello"); + inner.OnCompleted(); + Assert.Equal(new[] { "hello" }, result.ToArray()); + } +} diff --git a/R3Ext.Tests/TimingAdvancedTests.cs b/R3Ext.Tests/TimingAdvancedTests.cs new file mode 100644 index 0000000..ea0852e --- /dev/null +++ b/R3Ext.Tests/TimingAdvancedTests.cs @@ -0,0 +1,466 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516 +using Microsoft.Extensions.Time.Testing; +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class TimingAdvancedTests +{ + // โ”€โ”€ TimeInterval โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void TimeInterval_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.TimeInterval()); + } + + [Fact] + public void TimeInterval_MeasuresElapsedBetweenEmissions() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(1); // interval = 0 + tp.Advance(TimeSpan.FromSeconds(2)); + subject.OnNext(2); // interval โ‰ˆ 2s + + Assert.Equal(2, result.Count); + Assert.Equal(TimeSpan.Zero, result[0].Interval); + Assert.True(result[1].Interval >= TimeSpan.FromSeconds(1)); + Assert.Equal(1, result[0].Value); + Assert.Equal(2, result[1].Value); + } + + [Fact] + public void TimeInterval_FirstItem_HasZeroInterval() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(42); + + Assert.Single(result); + Assert.Equal(TimeSpan.Zero, result[0].Interval); + Assert.Equal(42, result[0].Value); + } + + [Fact] + public void TimeInterval_MultipleItems_MeasuresEachGap() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(1); + tp.Advance(TimeSpan.FromSeconds(1)); + subject.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(3)); + subject.OnNext(3); + + Assert.Equal(3, result.Count); + Assert.Equal(TimeSpan.Zero, result[0].Interval); + Assert.Equal(TimeSpan.FromSeconds(1), result[1].Interval); + Assert.Equal(TimeSpan.FromSeconds(3), result[2].Interval); + } + + [Fact] + public void TimeInterval_Deconstruct_WorksCorrectly() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(99); + + var (value, interval) = result[0]; + Assert.Equal(99, value); + Assert.Equal(TimeSpan.Zero, interval); + } + + [Fact] + public void TimeInterval_Completion_PropagatesDownstream() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList> result = subject.TimeInterval(tp).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + Assert.Single(result); + } + + // โ”€โ”€ DelayWhen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void DelayWhen_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.DelayWhen(_ => Observable.Return(Unit.Default))); + } + + [Fact] + public void DelayWhen_NullSelector_ThrowsArgumentNullException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.DelayWhen(null!)); + } + + [Fact] + public void DelayWhen_EmitsAfterDurationObservable() + { + Subject subject = new(); + Subject trigger = new(); + LiveList result = subject.DelayWhen(_ => trigger).ToLiveList(); + + subject.OnNext(42); + Assert.Empty(result.ToArray()); // not emitted yet + + trigger.OnNext(Unit.Default); + Assert.Equal(new[] { 42 }, result.ToArray()); // now emitted + } + + [Fact] + public void DelayWhen_MultipleItems_EachDelayedIndependently() + { + Subject subject = new(); + Subject trigger1 = new(); + Subject trigger2 = new(); + int callCount = 0; + Subject[] triggers = [trigger1, trigger2]; + + LiveList result = subject.DelayWhen(_ => triggers[callCount++]).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + + Assert.Empty(result.ToArray()); + + trigger2.OnNext(Unit.Default); // item 2 fires first + Assert.Equal(new[] { 2 }, result.ToArray()); + + trigger1.OnNext(Unit.Default); // item 1 fires second + Assert.Equal(new[] { 2, 1 }, result.ToArray()); + } + + [Fact] + public void DelayWhen_DurationCompletesWithoutEmitting_SkipsItem() + { + Subject subject = new(); + Subject trigger = new(); + LiveList result = subject.DelayWhen(_ => trigger).ToLiveList(); + + subject.OnNext(42); + trigger.OnCompleted(); // completes without emitting + + Assert.Empty(result.ToArray()); // item was not emitted + } + + [Fact] + public void DelayWhen_SourceCompletion_WaitsForInFlightItems() + { + Subject subject = new(); + Subject trigger = new(); + LiveList result = subject.DelayWhen(_ => trigger).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.False(result.IsCompleted); // still waiting for trigger + + trigger.OnNext(Unit.Default); + Assert.Equal(new[] { 1 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + [Fact] + public void DelayWhen_WithSubscriptionDelay_NullDelay_Throws() + { + var source = Observable.Return(1); + Assert.Throws(() => + source.DelayWhen(_ => Observable.Return(Unit.Default), null!)); + } + + [Fact] + public void DelayWhen_WithSubscriptionDelay_DelaysSubscription() + { + Subject source = new(); + Subject subscriptionTrigger = new(); + Subject itemTrigger = new(); + LiveList result = source.DelayWhen(_ => itemTrigger, subscriptionTrigger).ToLiveList(); + + // Items emitted before the subscription delay fires are lost. + source.OnNext(1); + Assert.Empty(result.ToArray()); + + subscriptionTrigger.OnNext(Unit.Default); // subscription starts now + source.OnNext(2); // this one is captured + itemTrigger.OnNext(Unit.Default); + + Assert.Equal(new[] { 2 }, result.ToArray()); + } + + // โ”€โ”€ RateLimit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void RateLimit_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.RateLimit(2, TimeSpan.FromSeconds(1))); + } + + [Fact] + public void RateLimit_ZeroCount_ThrowsArgumentOutOfRangeException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.RateLimit(0, TimeSpan.FromSeconds(1))); + } + + [Fact] + public void RateLimit_NegativePeriod_ThrowsArgumentOutOfRangeException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.RateLimit(2, TimeSpan.FromMilliseconds(-1))); + } + + [Fact] + public void RateLimit_AllowsUpToCountPerPeriod() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList result = subject.RateLimit(2, TimeSpan.FromSeconds(1), tp).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); // 3rd queued + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + + tp.Advance(TimeSpan.FromSeconds(1)); // next window + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void RateLimit_UnderLimit_EmitsAllImmediately() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList result = subject.RateLimit(5, TimeSpan.FromSeconds(1), tp).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void RateLimit_MultipleWindows_DrainsQueueAcrossWindows() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList result = subject.RateLimit(2, TimeSpan.FromSeconds(1), tp).ToLiveList(); + + // Flood 6 items; 2 per window + for (int i = 1; i <= 6; i++) + { + subject.OnNext(i); + } + + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + + tp.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(new[] { 1, 2, 3, 4 }, result.ToArray()); + + tp.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, result.ToArray()); + } + + [Fact] + public void RateLimit_SourceCompletion_FlushesRemainingQueue() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + LiveList result = subject.RateLimit(1, TimeSpan.FromSeconds(1), tp).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnCompleted(); + + // All items flushed on completion regardless of window + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + Assert.True(result.IsCompleted); + } + + // โ”€โ”€ BufferWithOverflow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void BufferWithOverflow_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.BufferWithOverflow(2)); + } + + [Fact] + public void BufferWithOverflow_ZeroCapacity_ThrowsArgumentOutOfRangeException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.BufferWithOverflow(0)); + } + + [Fact] + public void BufferWithOverflow_DropOldest_RemovesOldItem() + { + Subject subject = new(); + LiveList result = subject.BufferWithOverflow(2, OverflowStrategy.DropOldest).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + + Assert.Contains(2, result.ToArray()); + Assert.Contains(3, result.ToArray()); + } + + [Fact] + public void BufferWithOverflow_DropLatest_IgnoresIncomingItemWhenFull() + { + Subject subject = new(); + LiveList result = subject.BufferWithOverflow(2, OverflowStrategy.DropLatest).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); // 3 is dropped + + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + Assert.DoesNotContain(3, result.ToArray()); + } + + [Fact] + public void BufferWithOverflow_Error_SignalsErrorOnOverflow() + { + Subject subject = new(); + List errors = new(); + LiveList result = subject + .BufferWithOverflow(2, OverflowStrategy.Error) + .Do(onErrorResume: ex => errors.Add(ex)) + .ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); // 3 triggers error + + Assert.Single(errors); + Assert.IsType(errors[0]); + Assert.Equal(new[] { 1, 2 }, result.ToArray()); + } + + [Fact] + public void BufferWithOverflow_UnderCapacity_PassesAllItemsThrough() + { + Subject subject = new(); + LiveList result = subject.BufferWithOverflow(10, OverflowStrategy.DropOldest).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + + Assert.Equal(new[] { 1, 2, 3 }, result.ToArray()); + } + + [Fact] + public void BufferWithOverflow_Completion_PropagatesDownstream() + { + Subject subject = new(); + LiveList result = subject.BufferWithOverflow(5).ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + Assert.Single(result); + } + + // โ”€โ”€ Chunked โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void Chunked_NullSource_ThrowsArgumentNullException() + { + Observable? source = null; + Assert.Throws(() => source!.Chunked(3)); + } + + [Fact] + public void Chunked_ZeroSize_ThrowsArgumentOutOfRangeException() + { + var source = Observable.Return(1); + Assert.Throws(() => source.Chunked(0)); + } + + [Fact] + public void Chunked_NonOverlapping_EmitsCorrectChunks() + { + Subject subject = new(); + LiveList result = subject.Chunked(3).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnNext(4); subject.OnNext(5); subject.OnNext(6); + subject.OnCompleted(); + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 1, 2, 3 }, result[0]); + Assert.Equal(new[] { 4, 5, 6 }, result[1]); + } + + [Fact] + public void Chunked_PartialChunk_NotEmittedOnCompletion() + { + Subject subject = new(); + LiveList result = subject.Chunked(3).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); // only 2, never fills the chunk + subject.OnCompleted(); + + Assert.Empty(result); + Assert.True(result.IsCompleted); + } + + [Fact] + public void Chunked_OverlappingStep_EmitsCorrectWindows() + { + Subject subject = new(); + LiveList result = subject.Chunked(size: 3, step: 1).ToLiveList(); + + subject.OnNext(1); subject.OnNext(2); subject.OnNext(3); + subject.OnNext(4); + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 1, 2, 3 }, result[0]); + Assert.Equal(new[] { 2, 3, 4 }, result[1]); + } + + [Fact] + public void Chunked_StepLargerThanSize_LeavesGapsBetweenChunks() + { + Subject subject = new(); + LiveList result = subject.Chunked(size: 2, step: 3).ToLiveList(); + + // items: 1 2 3 4 5 6 + // chunk1: ^ ^ (starts at count=0, size=2) + // chunk2: ^ ^ (starts at count=3, size=2) + subject.OnNext(1); subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); subject.OnNext(5); + subject.OnNext(6); + subject.OnCompleted(); + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 1, 2 }, result[0]); + Assert.Equal(new[] { 4, 5 }, result[1]); + } + + [Fact] + public void Chunked_SingleItemChunks_EmitsEachItem() + { + Subject subject = new(); + LiveList result = subject.Chunked(size: 1).ToLiveList(); + + subject.OnNext(10); subject.OnNext(20); subject.OnNext(30); + + Assert.Equal(3, result.Count); + Assert.Equal(new[] { 10 }, result[0]); + Assert.Equal(new[] { 20 }, result[1]); + Assert.Equal(new[] { 30 }, result[2]); + } +} diff --git a/R3Ext.Tests/TransformAsyncCacheTests.cs b/R3Ext.Tests/TransformAsyncCacheTests.cs index 91f32f0..61cfc7e 100644 --- a/R3Ext.Tests/TransformAsyncCacheTests.cs +++ b/R3Ext.Tests/TransformAsyncCacheTests.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using R3; -using R3.DynamicData.Cache; +using R3Ext.DynamicData.Cache; #pragma warning disable SA1516, SA1503, SA1513, SA1107, SA1502, SA1515, SA1508 namespace R3Ext.Tests; @@ -34,7 +34,7 @@ public async Task TransformAsync_BasicTransformation() { foreach (var change in changeSet) { - if (change.Reason == R3.DynamicData.Kernel.ChangeReason.Add) + if (change.Reason == R3Ext.DynamicData.Kernel.ChangeReason.Add) { results.Add(change.Current); if (results.Count == 2) completionTcs.TrySetResult(true); @@ -86,7 +86,7 @@ public async Task TransformAsync_WithCancellation() { foreach (var change in changeSet) { - if (change.Reason == R3.DynamicData.Kernel.ChangeReason.Add) + if (change.Reason == R3Ext.DynamicData.Kernel.ChangeReason.Add) { results.Add(change.Current); } @@ -110,7 +110,7 @@ public async Task TransformAsync_WithCancellation() public async Task TransformAsync_Update() { var cache = new SourceCache(p => p.Id); - var results = new List>(); + var results = new List>(); var emitCount = 0; var tcs1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var tcs2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -140,7 +140,7 @@ public async Task TransformAsync_Update() await tcs1.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Single(results); - Assert.Equal(R3.DynamicData.Kernel.ChangeReason.Add, results[0].First().Reason); + Assert.Equal(R3Ext.DynamicData.Kernel.ChangeReason.Add, results[0].First().Reason); // Update the value - need new TCS for update itemTcs[1] = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -149,7 +149,7 @@ public async Task TransformAsync_Update() await tcs2.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(2, results.Count); - Assert.Equal(R3.DynamicData.Kernel.ChangeReason.Update, results[1].First().Reason); + Assert.Equal(R3Ext.DynamicData.Kernel.ChangeReason.Update, results[1].First().Reason); Assert.Equal("ALICIA", results[1].First().Current); } } diff --git a/R3Ext.Tests/TransformManyDedupTests.cs b/R3Ext.Tests/TransformManyDedupTests.cs index c28669a..be26e7b 100644 --- a/R3Ext.Tests/TransformManyDedupTests.cs +++ b/R3Ext.Tests/TransformManyDedupTests.cs @@ -3,9 +3,9 @@ using System.Linq; using System.Threading.Tasks; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; -using R3.DynamicData.List; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; +using R3Ext.DynamicData.List; using Xunit; #pragma warning disable SA1503, SA1513, SA1515, SA1107, SA1502, SA1508, SA1516 diff --git a/R3Ext.Tests/TransformSafeCacheTests.cs b/R3Ext.Tests/TransformSafeCacheTests.cs index 4499449..3db35e0 100644 --- a/R3Ext.Tests/TransformSafeCacheTests.cs +++ b/R3Ext.Tests/TransformSafeCacheTests.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using R3; -using R3.DynamicData.Cache; -using R3.DynamicData.Kernel; +using R3Ext.DynamicData.Cache; +using R3Ext.DynamicData.Kernel; #pragma warning disable SA1516, SA1503, SA1513, SA1107, SA1502, SA1515 namespace R3Ext.Tests; diff --git a/R3Ext.Tests/TransformationExtensionsTests.cs b/R3Ext.Tests/TransformationExtensionsTests.cs new file mode 100644 index 0000000..a1a264a --- /dev/null +++ b/R3Ext.Tests/TransformationExtensionsTests.cs @@ -0,0 +1,293 @@ +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class TransformationExtensionsTests +{ + // โ”€โ”€ MapTo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void MapTo_ReplacesAllValues() + { + Subject subject = new(); + LiveList result = subject.MapTo("x").ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + + Assert.Equal(new[] { "x", "x", "x" }, result.ToArray()); + } + + [Fact] + public void MapTo_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.MapTo("x").ToLiveList(); + + subject.OnNext(1); + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void MapTo_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.MapTo("x")); + } + + [Fact] + public void MapTo_WorksWithNullConstant() + { + Subject subject = new(); + LiveList result = subject.MapTo(null).ToLiveList(); + + subject.OnNext(1); + + Assert.Single(result); + Assert.Null(result[0]); + } + + // โ”€โ”€ CompactMap (reference type) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void CompactMap_ReferenceType_FiltersNulls() + { + Subject subject = new(); + LiveList result = subject.CompactMap(x => x % 2 == 0 ? x.ToString() : null).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + + Assert.Equal(new[] { "2", "4" }, result.ToArray()); + } + + [Fact] + public void CompactMap_ReferenceType_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.CompactMap(x => x.ToString()).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void CompactMap_ReferenceType_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.CompactMap(x => x.ToString())); + } + + [Fact] + public void CompactMap_ReferenceType_ThrowsOnNullSelector() + { + Subject subject = new(); + Assert.Throws(() => subject.CompactMap(null!)); + } + + // โ”€โ”€ CompactMap (value type) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void CompactMap_ValueType_FiltersNulls() + { + Subject subject = new(); + LiveList result = subject.CompactMap(x => x % 2 == 0 ? (int?)x : null).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + + Assert.Equal(new[] { 2, 4 }, result.ToArray()); + } + + [Fact] + public void CompactMap_ValueType_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.CompactMap(x => (int?)x).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void CompactMap_ValueType_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.CompactMap(x => x)); + } + + [Fact] + public void CompactMap_ValueType_AllNullsProducesEmpty() + { + Subject subject = new(); + LiveList result = subject.CompactMap(_ => (int?)null).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnCompleted(); + + Assert.Empty(result); + Assert.True(result.IsCompleted); + } + + // โ”€โ”€ WithIndex โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void WithIndex_EmitsValueAndZeroBasedIndex() + { + Subject subject = new(); + LiveList<(string Value, int Index)> result = subject.WithIndex().ToLiveList(); + + subject.OnNext("a"); + subject.OnNext("b"); + subject.OnNext("c"); + + Assert.Equal(new[] { ("a", 0), ("b", 1), ("c", 2) }, result.ToArray()); + } + + [Fact] + public void WithIndex_CompletionPropagates() + { + Subject subject = new(); + LiveList<(int Value, int Index)> result = subject.WithIndex().ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void WithIndex_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.WithIndex()); + } + + [Fact] + public void WithIndex_IndexIncrementsMonotonically() + { + Subject subject = new(); + LiveList<(int Value, int Index)> result = subject.WithIndex().ToLiveList(); + + for (int i = 0; i < 5; i++) + { + subject.OnNext(i * 10); + } + + int[] indices = result.Select(t => t.Index).ToArray(); + Assert.Equal(new[] { 0, 1, 2, 3, 4 }, indices); + } + + // โ”€โ”€ RunningFold โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void RunningFold_AccumulatesValues() + { + Subject subject = new(); + LiveList result = subject.RunningFold(0, (acc, x) => acc + x).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + + Assert.Equal(new[] { 1, 3, 6 }, result.ToArray()); + } + + [Fact] + public void RunningFold_UsesSeedAsInitialAccumulator() + { + Subject subject = new(); + LiveList result = subject.RunningFold(10, (acc, x) => acc + x).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + + Assert.Equal(new[] { 11, 13 }, result.ToArray()); + } + + [Fact] + public void RunningFold_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.RunningFold(0, (acc, x) => acc + x).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void RunningFold_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.RunningFold(0, (acc, x) => acc + x)); + } + + [Fact] + public void RunningFold_ThrowsOnNullAccumulator() + { + Subject subject = new(); + Assert.Throws(() => subject.RunningFold(0, null!)); + } + + // โ”€โ”€ RunningReduce โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + [Fact] + public void RunningReduce_AccumulatesWithoutSeed() + { + Subject subject = new(); + LiveList result = subject.RunningReduce((acc, x) => acc + x).ToLiveList(); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + + Assert.Equal(new[] { 1, 3, 6 }, result.ToArray()); + } + + [Fact] + public void RunningReduce_CompletionPropagates() + { + Subject subject = new(); + LiveList result = subject.RunningReduce((acc, x) => acc + x).ToLiveList(); + + subject.OnCompleted(); + + Assert.True(result.IsCompleted); + } + + [Fact] + public void RunningReduce_ThrowsOnNullSource() + { + Observable? source = null; + Assert.Throws(() => source!.RunningReduce((acc, x) => acc + x)); + } + + [Fact] + public void RunningReduce_ThrowsOnNullAccumulator() + { + Subject subject = new(); + Assert.Throws(() => subject.RunningReduce(null!)); + } + + [Fact] + public void RunningReduce_FirstValueIsEmittedUnchanged() + { + Subject subject = new(); + LiveList result = subject.RunningReduce((acc, x) => acc * x).ToLiveList(); + + subject.OnNext(5); + + Assert.Equal(new[] { 5 }, result.ToArray()); + } +} diff --git a/R3Ext.Tests/WindowingOperatorsTests.cs b/R3Ext.Tests/WindowingOperatorsTests.cs new file mode 100644 index 0000000..3d61ab0 --- /dev/null +++ b/R3Ext.Tests/WindowingOperatorsTests.cs @@ -0,0 +1,520 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025, SA1520, SA1513, SA1508, SA1516, SA1028 +using Microsoft.Extensions.Time.Testing; +using R3; +using R3.Collections; + +namespace R3Ext.Tests; + +public class WindowingOperatorsTests +{ + // ----------------------------------------------------------------------- + // argument validationWindowCount + // ----------------------------------------------------------------------- + + [Fact] + public void WindowCount_NullSource_Throws() + { + Observable? source = null; + Assert.Throws(() => source!.WindowCount(3)); + } + + [Fact] + public void WindowCount_ZeroCount_Throws() + { + Assert.Throws(() => Observable.Return(1).WindowCount(0)); + } + + [Fact] + public void WindowCount_NegativeSkip_Throws() + { + Assert.Throws(() => Observable.Return(1).WindowCount(3, -1)); + } + + // ----------------------------------------------------------------------- + // non-overlappingWindowCount + // ----------------------------------------------------------------------- + + [Fact] + public void WindowCount_NonOverlapping_EmitsCorrectWindows() + { + Subject subject = new(); + List windows = new(); + subject.WindowCount(3).Subscribe(window => + { + List items = new(); + + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + subject.OnNext(5); + subject.OnNext(6); + subject.OnCompleted(); + + Assert.Equal(2, windows.Count); + Assert.Equal(new[] { 1, 2, 3 }, windows[0]); + Assert.Equal(new[] { 4, 5, 6 }, windows[1]); + } + + [Fact] + public void WindowCount_NonOverlapping_IncompleteLastWindow_EmittedOnSourceComplete() + { + Subject subject = new(); + List windows = new(); + subject.WindowCount(3).Subscribe(window => + { + List items = new(); + + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + subject.OnNext(5); + subject.OnCompleted(); + + Assert.Equal(2, windows.Count); + Assert.Equal(new[] { 1, 2, 3 }, windows[0]); + Assert.Equal(new[] { 4, 5 }, windows[1]); + } + + [Fact] + public void WindowCount_NonOverlapping_SingleElement_EachWindowHasOneItem() + { + Subject subject = new(); + List windows = new(); + subject.WindowCount(1).Subscribe(window => + { + List items = new(); + + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(10); + subject.OnNext(20); + subject.OnNext(30); + subject.OnCompleted(); + + Assert.Equal(3, windows.Count); + Assert.Equal(new[] { 10 }, windows[0]); + Assert.Equal(new[] { 20 }, windows[1]); + Assert.Equal(new[] { 30 }, windows[2]); + } + + // ----------------------------------------------------------------------- + // overlappingWindowCount + // ----------------------------------------------------------------------- + + [Fact] + public void WindowCount_Overlapping_WindowsShareElements() + { + Subject subject = new(); + List windows = new(); + + // skip=2, count=3: W0={0,1,2}, W1={2,3,4} + subject.WindowCount(count: 3, skip: 2).Subscribe(window => + { + List items = new(); + + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(0); + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + subject.OnCompleted(); + + Assert.True(windows.Count >= 2); + Assert.Equal(new[] { 0, 1, 2 }, windows[0]); + Assert.Equal(new[] { 2, 3, 4 }, windows[1]); + } + + [Fact] + public void WindowCount_Overlapping_ExplicitSkipEqualsCount_NonOverlapping() + { + Subject subject = new(); + List windows = new(); + subject.WindowCount(count: 2, skip: 2).Subscribe(window => + { + List items = new(); + + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + subject.OnCompleted(); + + Assert.Equal(2, windows.Count); + Assert.Equal(new[] { 1, 2 }, windows[0]); + Assert.Equal(new[] { 3, 4 }, windows[1]); + } + + // ----------------------------------------------------------------------- + // argument validationWindowTime + // ----------------------------------------------------------------------- + + [Fact] + public void WindowTime_NullSource_Throws() + { + Observable? source = null; + Assert.Throws(() => source!.WindowTime(TimeSpan.FromSeconds(1))); + } + + [Fact] + public void WindowTime_ZeroTimeSpan_Throws() + { + Assert.Throws(() => + Observable.Return(1).WindowTime(TimeSpan.Zero)); + } + + [Fact] + public void WindowTime_NegativeTimeSpan_Throws() + { + Assert.Throws(() => + Observable.Return(1).WindowTime(TimeSpan.FromMilliseconds(-1))); + } + + // ----------------------------------------------------------------------- + // behaviourWindowTime + // ----------------------------------------------------------------------- + + [Fact] + public async Task WindowTime_CreatesTimeBasedWindows() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + subject.WindowTime(TimeSpan.FromSeconds(1), tp).Subscribe(window => + { + List items = new(); + + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); + subject.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(1)); + + subject.OnNext(3); + tp.Advance(TimeSpan.FromSeconds(1)); + + subject.OnCompleted(); + await Task.Yield(); + + Assert.True(windows.Count >= 2); + Assert.Equal(new[] { 1, 2 }, windows[0]); + Assert.Equal(new[] { 3 }, windows[1]); + } + + [Fact] + public async Task WindowTime_EmptyWindowsAreEmitted() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + int windowCount = 0; + subject.WindowTime(TimeSpan.FromSeconds(1), tp).Subscribe(_ => windowCount++); + + tp.Advance(TimeSpan.FromSeconds(1)); + tp.Advance(TimeSpan.FromSeconds(1)); + + subject.OnCompleted(); + await Task.Yield(); + + Assert.True(windowCount >= 2, $"Expected at least 2 windows, got {windowCount}"); + } + + [Fact] + public async Task WindowTime_SourceCompleteFlushesCurrentWindow() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + subject.WindowTime(TimeSpan.FromSeconds(10), tp).Subscribe(window => + { + List items = new(); + + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(42); + subject.OnCompleted(); + await Task.Yield(); + + Assert.Single(windows); + Assert.Equal(new[] { 42 }, windows[0]); + } + + // ----------------------------------------------------------------------- + // WindowTime with argument validationmaxCount + // ----------------------------------------------------------------------- + + [Fact] + public void WindowTimeMaxCount_ZeroMaxCount_Throws() + { + Assert.Throws(() => + Observable.Return(1).WindowTime(TimeSpan.FromSeconds(1), maxCount: 0)); + } + + // ----------------------------------------------------------------------- + // WindowTime with behaviourmaxCount + // ----------------------------------------------------------------------- + + [Fact] + public async Task WindowTimeMaxCount_ClosesOnCountBeforeTimer() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + subject.WindowTime(TimeSpan.FromSeconds(10), maxCount: 2, timeProvider: tp).Subscribe(window => + { + List items = new(); + + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); + subject.OnNext(2); + subject.OnNext(3); + subject.OnNext(4); + subject.OnCompleted(); + await Task.Yield(); + + Assert.True(windows.Count >= 2); + Assert.Equal(new[] { 1, 2 }, windows[0]); + Assert.Equal(new[] { 3, 4 }, windows[1]); + } + + [Fact] + public async Task WindowTimeMaxCount_ClosesOnTimerBeforeCount() + { + FakeTimeProvider tp = new(); + Subject subject = new(); + List windows = new(); + subject.WindowTime(TimeSpan.FromSeconds(1), maxCount: 10, timeProvider: tp).Subscribe(window => + { + List items = new(); + + window.Subscribe(items.Add, _ => { }, _ => windows.Add(items.ToArray())); + }); + + subject.OnNext(1); + subject.OnNext(2); + tp.Advance(TimeSpan.FromSeconds(1)); + + subject.OnCompleted(); + await Task.Yield(); + + Assert.True(windows.Count >= 1); + Assert.Equal(new[] { 1, 2 }, windows[0]); + } + + // ----------------------------------------------------------------------- + // argument validationBufferToggle + // ----------------------------------------------------------------------- + + [Fact] + public void BufferToggle_NullSource_Throws() + { + Observable? source = null; + Assert.Throws(() => + source!.BufferToggle(Observable.Empty(), _ => Observable.Empty())); + } + + [Fact] + public void BufferToggle_NullOpenings_Throws() + { + Observable? nullOpenings = null; + Assert.Throws(() => + Observable.Return(1).BufferToggle(nullOpenings!, _ => Observable.Empty())); + } + + [Fact] + public void BufferToggle_NullClosingSelector_Throws() + { + Assert.Throws(() => + Observable.Return(1).BufferToggle( + Observable.Empty(), + (Func>)null!)); + } + + // ----------------------------------------------------------------------- + // behaviourBufferToggle + // ----------------------------------------------------------------------- + + [Fact] + public void BufferToggle_CollectsItemsInOpenWindows() + { + Subject source = new(); + Subject opens = new(); + Subject closes = new(); + LiveList result = source.BufferToggle(opens, _ => (Observable)closes).ToLiveList(); + + opens.OnNext(Unit.Default); + source.OnNext(1); + source.OnNext(2); + closes.OnNext(Unit.Default); + + Assert.Single(result); + Assert.Equal(new[] { 1, 2 }, result[0]); + } + + [Fact] + public void BufferToggle_ItemsBeforeOpenAreNotCollected() + { + Subject source = new(); + Subject opens = new(); + Subject closes = new(); + LiveList result = source.BufferToggle(opens, _ => (Observable)closes).ToLiveList(); + + source.OnNext(99); + opens.OnNext(Unit.Default); + source.OnNext(1); + closes.OnNext(Unit.Default); + + Assert.Single(result); + Assert.Equal(new[] { 1 }, result[0]); + } + + [Fact] + public void BufferToggle_MultipleConcurrentBuffers() + { + Subject source = new(); + Subject opens = new(); + Subject closes1 = new(); + Subject closes2 = new(); + int callCount = 0; + List> closers = new() { closes1, closes2 }; + + LiveList result = source.BufferToggle(opens, _ => + { + return (Observable)closers[callCount++]; + }).ToLiveList(); + + opens.OnNext(Unit.Default); + source.OnNext(1); + opens.OnNext(Unit.Default); + source.OnNext(2); + closes1.OnNext(Unit.Default); + source.OnNext(3); + closes2.OnNext(Unit.Default); + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 1, 2 }, result[0]); + Assert.Equal(new[] { 2, 3 }, result[1]); + } + + [Fact] + public void BufferToggle_SourceComplete_EmitsAllOpenBuffers() + { + Subject source = new(); + Subject opens = new(); + Subject closes = new(); + LiveList result = source.BufferToggle(opens, _ => (Observable)closes).ToLiveList(); + + opens.OnNext(Unit.Default); + source.OnNext(5); + source.OnNext(6); + source.OnCompleted(); + + Assert.Single(result); + Assert.Equal(new[] { 5, 6 }, result[0]); + Assert.True(result.IsCompleted); + } + + // ----------------------------------------------------------------------- + // argument validationBufferWhen + // ----------------------------------------------------------------------- + + [Fact] + public void BufferWhen_NullSource_Throws() + { + Observable? source = null; + Assert.Throws(() => + source!.BufferWhen(() => Observable.Empty())); + } + + [Fact] + public void BufferWhen_NullClosingSelector_Throws() + { + Assert.Throws(() => + Observable.Return(1).BufferWhen((Func>)null!)); + } + + // ----------------------------------------------------------------------- + // behaviourBufferWhen + // ----------------------------------------------------------------------- + + [Fact] + public void BufferWhen_EmitsOnClose() + { + Subject source = new(); + Subject closer = new(); + LiveList result = source.BufferWhen(() => (Observable)closer).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + closer.OnNext(Unit.Default); + + Assert.Single(result); + Assert.Equal(new[] { 1, 2 }, result[0]); + } + + [Fact] + public void BufferWhen_MultipleCloses_EmitsSuccessiveBuffers() + { + Subject source = new(); + Subject closer = new(); + LiveList result = source.BufferWhen(() => (Observable)closer).ToLiveList(); + + source.OnNext(1); + source.OnNext(2); + closer.OnNext(Unit.Default); + + Assert.Equal(new[] { 1, 2 }, result[0]); + + source.OnNext(3); + closer.OnNext(Unit.Default); + + Assert.Equal(2, result.Count); + Assert.Equal(new[] { 3 }, result[1]); + } + + [Fact] + public void BufferWhen_SourceComplete_FlushesRemainingBuffer() + { + Subject source = new(); + Subject closer = new(); + LiveList result = source.BufferWhen(() => (Observable)closer).ToLiveList(); + + source.OnNext(7); + source.OnNext(8); + source.OnCompleted(); + + Assert.Single(result); + Assert.Equal(new[] { 7, 8 }, result[0]); + Assert.True(result.IsCompleted); + } + + [Fact] + public void BufferWhen_CloseBeforeAnyItems_EmitsEmptyBuffer() + { + Subject source = new(); + Subject closer = new(); + LiveList result = source.BufferWhen(() => (Observable)closer).ToLiveList(); + + closer.OnNext(Unit.Default); + + Assert.Single(result); + Assert.Empty(result[0]); + } +} diff --git a/R3Ext.sln b/R3Ext.sln index 4ae0058..5acbfed 100644 --- a/R3Ext.sln +++ b/R3Ext.sln @@ -34,10 +34,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "[ Solution ]", "[ Solution logo.png = logo.png EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "R3.DynamicData", "R3.DynamicData\R3.DynamicData.csproj", "{8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "R3.DynamicData.Tests", "R3.DynamicData.Tests\R3.DynamicData.Tests.csproj", "{425BABB2-FAD3-4A02-8895-65A1FACDC4EA}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -120,30 +116,6 @@ Global {826B2351-773C-4D06-915F-C1BD594D769F}.Release|x64.Build.0 = Release|Any CPU {826B2351-773C-4D06-915F-C1BD594D769F}.Release|x86.ActiveCfg = Release|Any CPU {826B2351-773C-4D06-915F-C1BD594D769F}.Release|x86.Build.0 = Release|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Debug|x64.ActiveCfg = Debug|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Debug|x64.Build.0 = Debug|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Debug|x86.ActiveCfg = Debug|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Debug|x86.Build.0 = Debug|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Release|Any CPU.Build.0 = Release|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Release|x64.ActiveCfg = Release|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Release|x64.Build.0 = Release|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Release|x86.ActiveCfg = Release|Any CPU - {8E1C2198-03C2-4EBA-979D-1DDC8C843CEC}.Release|x86.Build.0 = Release|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Debug|x64.ActiveCfg = Debug|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Debug|x64.Build.0 = Debug|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Debug|x86.ActiveCfg = Debug|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Debug|x86.Build.0 = Debug|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Release|Any CPU.Build.0 = Release|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Release|x64.ActiveCfg = Release|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Release|x64.Build.0 = Release|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Release|x86.ActiveCfg = Release|Any CPU - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -153,6 +125,5 @@ Global {D0339522-19B8-4719-9130-DEFC302357AF} = {F2022F80-E320-47C7-A4FD-EE96861BBE9F} {A4A39F1F-495B-4A19-9E4C-9B3F6D4E6F77} = {F2022F80-E320-47C7-A4FD-EE96861BBE9F} {826B2351-773C-4D06-915F-C1BD594D769F} = {596203A4-13AC-4F73-9740-9D3374BE6608} - {425BABB2-FAD3-4A02-8895-65A1FACDC4EA} = {F2022F80-E320-47C7-A4FD-EE96861BBE9F} EndGlobalSection EndGlobal diff --git a/R3Ext/ErrorHandling/ErrorHandlingExtensions.Advanced.cs b/R3Ext/ErrorHandling/ErrorHandlingExtensions.Advanced.cs new file mode 100644 index 0000000..342b74a --- /dev/null +++ b/R3Ext/ErrorHandling/ErrorHandlingExtensions.Advanced.cs @@ -0,0 +1,429 @@ +using R3; + +namespace R3Ext; + +public static partial class ErrorHandlingExtensions +{ + /// + /// Retry with a user-controlled notifier observable. When source fails the notifier receives + /// the exception. If the notifier emits a value the source is re-subscribed. If the notifier + /// completes the downstream completes. + /// + public static Observable RetryWhen( + this Observable source, + Func, Observable> handler) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + IDisposable? retrySubscription = null; + Subject notifier = new(); + Observable retrySignal = handler(notifier); + + void SubscribeOnce() + { + upstream?.Dispose(); + upstream = source.OnErrorResumeAsFailure().Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + // Notify outside the gate to avoid reentrancy deadlock: the handler + // may synchronously re-trigger SubscribeOnce via the retrySignal. + bool isDisposed; + using (gate.EnterScope()) + { + isDisposed = disposed; + } + + if (isDisposed) + { + return; + } + + if (r.IsFailure) + { + notifier.OnNext(r.Exception); + } + else + { + notifier.OnCompleted(); + } + }); + } + + retrySubscription = retrySignal.Subscribe( + _ => + { + bool isDisposed; + using (gate.EnterScope()) + { + isDisposed = disposed; + } + + if (!isDisposed) + { + SubscribeOnce(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + SubscribeOnce(); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + retrySubscription?.Dispose(); + } + }); + }); + } + + /// + /// On any OnErrorResume event, emit and complete the sequence. + /// Unlike (which handles terminal failure completions), this + /// intercepts non-terminal OnErrorResume events. + /// + public static Observable ReplaceError(this Observable source, T fallbackValue) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + IDisposable? toDispose = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + toDispose = upstream; + observer.OnNext(fallbackValue); + observer.OnCompleted(); + } + + toDispose?.Dispose(); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// If source completes with success but emitted no values, emit + /// before the completion. Equivalent to DefaultIfEmpty. + /// + public static Observable ReplaceEmpty(this Observable source, T fallbackValue) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + hasValue = true; + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsSuccess && !hasValue) + { + observer.OnNext(fallbackValue); + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Like Select but catches exceptions thrown by and routes them + /// to OnErrorResume instead of crashing the pipeline. + /// + public static Observable SelectSafe( + this Observable source, + Func selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + try + { + observer.OnNext(selector(x)); + } + catch (Exception ex) + { + observer.OnErrorResume(ex); + } + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Like Where but catches exceptions thrown by and routes them + /// to OnErrorResume instead of crashing the pipeline. + /// + public static Observable WhereSafe(this Observable source, Func predicate) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + try + { + if (predicate(x)) + { + observer.OnNext(x); + } + } + catch (Exception ex) + { + observer.OnErrorResume(ex); + } + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Extensions/AggregateStreamExtensions.cs b/R3Ext/Extensions/AggregateStreamExtensions.cs new file mode 100644 index 0000000..481d445 --- /dev/null +++ b/R3Ext/Extensions/AggregateStreamExtensions.cs @@ -0,0 +1,716 @@ +using System.Numerics; +using R3; + +namespace R3Ext; + +/// +/// Stream aggregate operators that emit incremental aggregate values as each element arrives. +/// +public static class AggregateStreamExtensions +{ + /// + /// Emits the count of items received so far. + /// + public static Observable RunningCount(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + count++; + observer.OnNext(count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running sum using C# generic math. + /// + public static Observable RunningSum(this Observable source) + where T : INumber + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return source.Scan(T.Zero, (acc, x) => acc + x); + } + + /// + /// Emits the running average of a stream. + /// + public static Observable RunningAverage(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + double sum = 0d; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sum += x; + count++; + observer.OnNext(sum / count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running average of a stream as . + /// + public static Observable RunningAverage(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + double sum = 0d; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sum += x; + count++; + observer.OnNext(sum / count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running average of a stream. + /// + public static Observable RunningAverage(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + decimal sum = 0m; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sum += x; + count++; + observer.OnNext(sum / count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running average of an stream as . + /// + public static Observable RunningAverage(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + double sum = 0d; + int count = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sum += x; + count++; + observer.OnNext(sum / count); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running minimum value seen so far. + /// + public static Observable RunningMin(this Observable source) + where T : IComparable + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? min = default; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hasValue || x.CompareTo(min) < 0) + { + min = x; + hasValue = true; + } + + observer.OnNext(min!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running minimum value seen so far using a custom comparer. + /// + public static Observable RunningMin(this Observable source, IComparer comparer) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (comparer is null) + { + throw new ArgumentNullException(nameof(comparer)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? min = default; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hasValue || comparer.Compare(x, min) < 0) + { + min = x; + hasValue = true; + } + + observer.OnNext(min!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running maximum value seen so far. + /// + public static Observable RunningMax(this Observable source) + where T : IComparable + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? max = default; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hasValue || x.CompareTo(max) > 0) + { + max = x; + hasValue = true; + } + + observer.OnNext(max!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the running maximum value seen so far using a custom comparer. + /// + public static Observable RunningMax(this Observable source, IComparer comparer) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (comparer is null) + { + throw new ArgumentNullException(nameof(comparer)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? max = default; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hasValue || comparer.Compare(x, max) > 0) + { + max = x; + hasValue = true; + } + + observer.OnNext(max!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Extensions/CombinationExtensions.cs b/R3Ext/Extensions/CombinationExtensions.cs new file mode 100644 index 0000000..dbdb719 --- /dev/null +++ b/R3Ext/Extensions/CombinationExtensions.cs @@ -0,0 +1,978 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using R3; + +namespace R3Ext; + +/// +/// Combination and creation operator extensions for R3 observables. +/// +public static class CombinationExtensions +{ + /// + /// Subscribes to all sources concurrently and emits an array of the last value from each when all complete. + /// + public static Observable ForkJoin(params Observable[] sources) + => ForkJoin((IEnumerable>)sources); + + /// + /// Subscribes to all sources concurrently and emits an array of the last value from each when all complete. + /// + public static Observable ForkJoin(IEnumerable> sources) + { + ArgumentNullException.ThrowIfNull(sources); + + Observable[] sourceArray = sources.ToArray(); + + if (sourceArray.Length == 0) + { + return Observable.Return(Array.Empty()); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T?[] lastValues = new T?[sourceArray.Length]; + bool[] completed = new bool[sourceArray.Length]; + bool[] hasValue = new bool[sourceArray.Length]; + IDisposable?[] subscriptions = new IDisposable?[sourceArray.Length]; + + void CheckCompletion() + { + for (int k = 0; k < completed.Length; k++) + { + if (!completed[k]) + { + return; + } + } + + disposed = true; + + for (int k = 0; k < sourceArray.Length; k++) + { + if (!hasValue[k]) + { + observer.OnCompleted(Result.Failure(new InvalidOperationException($"Source at index {k} completed without emitting any value."))); + return; + } + } + + T[] result = new T[sourceArray.Length]; + for (int k = 0; k < sourceArray.Length; k++) + { + result[k] = lastValues[k]!; + } + + observer.OnNext(result); + observer.OnCompleted(); + } + + for (int i = 0; i < sourceArray.Length; i++) + { + int index = i; + subscriptions[i] = sourceArray[i].Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + lastValues[index] = x; + hasValue[index] = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed[index] = true; + CheckCompletion(); + } + }); + } + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + for (int k = 0; k < subscriptions.Length; k++) + { + subscriptions[k]?.Dispose(); + } + } + }); + }); + } + + /// + /// Subscribes to both sources concurrently and emits a tuple of the last values when both complete. + /// + public static Observable<(T1 Item1, T2 Item2)> ForkJoin(Observable source1, Observable source2) + { + ArgumentNullException.ThrowIfNull(source1); + ArgumentNullException.ThrowIfNull(source2); + + return Observable.Create<(T1 Item1, T2 Item2)>(observer => + { + Lock gate = new(); + bool disposed = false; + T1? last1 = default; + T2? last2 = default; + bool has1 = false; + bool has2 = false; + bool completed1 = false; + bool completed2 = false; + IDisposable? sub1 = null; + IDisposable? sub2 = null; + + void CheckCompletion() + { + if (!completed1 || !completed2) + { + return; + } + + disposed = true; + + if (!has1 || !has2) + { + observer.OnCompleted(Result.Failure(new InvalidOperationException("A source completed without emitting any value."))); + return; + } + + observer.OnNext((last1!, last2!)); + observer.OnCompleted(); + } + + sub1 = source1.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last1 = x; + has1 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed1 = true; + CheckCompletion(); + } + }); + + sub2 = source2.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last2 = x; + has2 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed2 = true; + CheckCompletion(); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub1?.Dispose(); + sub2?.Dispose(); + } + }); + }); + } + + /// + /// Subscribes to all three sources concurrently and emits a tuple of the last values when all complete. + /// + public static Observable<(T1 Item1, T2 Item2, T3 Item3)> ForkJoin( + Observable source1, Observable source2, Observable source3) + { + ArgumentNullException.ThrowIfNull(source1); + ArgumentNullException.ThrowIfNull(source2); + ArgumentNullException.ThrowIfNull(source3); + + return Observable.Create<(T1 Item1, T2 Item2, T3 Item3)>(observer => + { + Lock gate = new(); + bool disposed = false; + T1? last1 = default; + T2? last2 = default; + T3? last3 = default; + bool has1 = false; + bool has2 = false; + bool has3 = false; + bool completed1 = false; + bool completed2 = false; + bool completed3 = false; + IDisposable? sub1 = null; + IDisposable? sub2 = null; + IDisposable? sub3 = null; + + void CheckCompletion() + { + if (!completed1 || !completed2 || !completed3) + { + return; + } + + disposed = true; + + if (!has1 || !has2 || !has3) + { + observer.OnCompleted(Result.Failure(new InvalidOperationException("A source completed without emitting any value."))); + return; + } + + observer.OnNext((last1!, last2!, last3!)); + observer.OnCompleted(); + } + + sub1 = source1.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last1 = x; + has1 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed1 = true; + CheckCompletion(); + } + }); + + sub2 = source2.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last2 = x; + has2 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed2 = true; + CheckCompletion(); + } + }); + + sub3 = source3.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + last3 = x; + has3 = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(Result.Failure(ex)); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + return; + } + + completed3 = true; + CheckCompletion(); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub1?.Dispose(); + sub2?.Dispose(); + sub3?.Dispose(); + } + }); + }); + } + + /// + /// Subscribes to sources sequentially, moving to the next source when the current one completes + /// regardless of success or failure. + /// + public static Observable OnErrorResumeNext(params Observable[] sources) + { + ArgumentNullException.ThrowIfNull(sources); + + if (sources.Length == 0) + { + return Observable.Empty(); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? currentSub = null; + + void SubscribeAt(int i) + { + if (i >= sources.Length) + { + observer.OnCompleted(); + return; + } + + bool hasAdvanced = false; + + void Advance() + { + bool shouldAdvance; + using (gate.EnterScope()) + { + if (disposed || hasAdvanced) + { + shouldAdvance = false; + } + else + { + hasAdvanced = true; + shouldAdvance = true; + } + } + + if (shouldAdvance) + { + SubscribeAt(i + 1); + } + } + + currentSub = sources[i].Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + + Advance(); + }, + r => + { + Advance(); + }); + } + + SubscribeAt(0); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + currentSub?.Dispose(); + } + }); + }); + } + + /// + /// Continues with the next source when the current source completes (regardless of success or failure). + /// + public static Observable OnErrorResumeNext(this Observable source, Observable next) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(next); + + return OnErrorResumeNext(new Observable[] { source, next }); + } + + /// + /// At subscribe time, evaluates the condition and subscribes to the appropriate source. + /// + public static Observable Iif( + Func condition, Observable thenSource, Observable elseSource) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentNullException.ThrowIfNull(thenSource); + ArgumentNullException.ThrowIfNull(elseSource); + + return Observable.Defer(() => condition() ? thenSource : elseSource); + } + + /// + /// At subscribe time, evaluates the condition and subscribes to the appropriate source. + /// Alias for . + /// + public static Observable Condition( + Func condition, Observable thenSource, Observable elseSource) + => Iif(condition, thenSource, elseSource); + + /// + /// Determines whether two observable sequences are equal by comparing elements pairwise. + /// Emits true if both sequences have the same length and equal elements; false on first mismatch. + /// + public static Observable SequenceEqual( + this Observable source, + Observable second, + IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(second); + + comparer ??= EqualityComparer.Default; + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + Queue q1 = new(); + Queue q2 = new(); + bool s1Completed = false; + bool s2Completed = false; + IDisposable? sub1 = null; + IDisposable? sub2 = null; + + void TryAdvance() + { + while (q1.Count > 0 && q2.Count > 0) + { + T v1 = q1.Dequeue(); + T v2 = q2.Dequeue(); + + if (!comparer.Equals(v1, v2)) + { + disposed = true; + observer.OnNext(false); + observer.OnCompleted(); + sub1?.Dispose(); + sub2?.Dispose(); + return; + } + } + + if (s1Completed && s2Completed && q1.Count == 0 && q2.Count == 0) + { + disposed = true; + observer.OnNext(true); + observer.OnCompleted(); + return; + } + + if (s1Completed && q1.Count == 0 && q2.Count > 0) + { + disposed = true; + observer.OnNext(false); + observer.OnCompleted(); + sub2?.Dispose(); + return; + } + + if (s2Completed && q2.Count == 0 && q1.Count > 0) + { + disposed = true; + observer.OnNext(false); + observer.OnCompleted(); + sub1?.Dispose(); + } + } + + sub1 = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + q1.Enqueue(x); + TryAdvance(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + sub2?.Dispose(); + return; + } + + s1Completed = true; + TryAdvance(); + } + }); + + sub2 = second.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + q2.Enqueue(x); + TryAdvance(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsFailure) + { + disposed = true; + observer.OnCompleted(r); + sub1?.Dispose(); + return; + } + + s2Completed = true; + TryAdvance(); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub1?.Dispose(); + sub2?.Dispose(); + } + }); + }); + } + + /// + /// Repeats the source observable when the handler observable emits; stops when the handler completes. + /// + public static Observable RepeatWhen( + this Observable source, + Func, Observable> handler) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(handler); + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + Subject notifier = new(); + IDisposable? sourceSubscription = null; + IDisposable? handlerSubscription = null; + + void SubscribeToSource() + { + sourceSubscription?.Dispose(); + sourceSubscription = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldNotify; + Exception? exc; + using (gate.EnterScope()) + { + shouldNotify = !disposed; + exc = r.IsFailure ? r.Exception : null; + } + + if (shouldNotify) + { + notifier.OnNext(exc); + } + }); + } + + handlerSubscription = handler(notifier).Subscribe( + _ => + { + bool shouldResubscribe; + using (gate.EnterScope()) + { + shouldResubscribe = !disposed; + } + + if (shouldResubscribe) + { + SubscribeToSource(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + observer.OnCompleted(r); + } + }); + + SubscribeToSource(); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sourceSubscription?.Dispose(); + handlerSubscription?.Dispose(); + } + }); + }); + } + + /// + /// Creates an observable sequence by iterating a state machine, applying a result selector to each state. + /// + public static Observable Generate( + TState initialState, + Func condition, + Func iterate, + Func resultSelector) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentNullException.ThrowIfNull(iterate); + ArgumentNullException.ThrowIfNull(resultSelector); + + return Observable.Create(observer => + { + TState state = initialState; + while (condition(state)) + { + observer.OnNext(resultSelector(state)); + state = iterate(state); + } + + observer.OnCompleted(); + return Disposable.Empty; + }); + } + + /// + /// Creates an observable sequence by iterating a state machine, emitting each state value. + /// + public static Observable Generate( + TState initialState, + Func condition, + Func iterate) + => Generate(initialState, condition, iterate, static x => x); +} diff --git a/R3Ext/Extensions/FilteringExtensions.Advanced.cs b/R3Ext/Extensions/FilteringExtensions.Advanced.cs new file mode 100644 index 0000000..56eca75 --- /dev/null +++ b/R3Ext/Extensions/FilteringExtensions.Advanced.cs @@ -0,0 +1,823 @@ +using R3; + +namespace R3Ext; + +public static partial class FilteringExtensions +{ + /// + /// Suppresses all OnNext values; only passes through OnCompleted and OnErrorResume. + /// + public static Observable IgnoreElements(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + source.Subscribe( + _ => { }, + observer.OnErrorResume, + observer.OnCompleted)); + } + + /// + /// Emits false when the first value arrives then completes; emits true if the + /// source completes without emitting any value. + /// + public static Observable IsEmpty(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool hadValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + bool shouldComplete = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!hadValue) + { + hadValue = true; + disposed = true; + shouldComplete = true; + } + } + + if (shouldComplete) + { + observer.OnNext(false); + observer.OnCompleted(); + upstream?.Dispose(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool emitTrue = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + emitTrue = !hadValue; + } + + if (emitTrue) + { + observer.OnNext(true); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits false as soon as predicate fails then completes; emits true when the + /// source completes successfully if all values passed the predicate. + /// + public static Observable Every(this Observable source, Func predicate) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + bool shouldFail = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (!predicate(x)) + { + disposed = true; + shouldFail = true; + } + } + + if (shouldFail) + { + observer.OnNext(false); + observer.OnCompleted(); + upstream?.Dispose(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool emitTrue = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + emitTrue = r.IsSuccess; + } + + if (emitTrue) + { + observer.OnNext(true); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Alias for . Emits false as soon as predicate fails; emits + /// true on successful completion if all values passed. + /// + public static Observable All(this Observable source, Func predicate) + => source.Every(predicate); + + /// + /// Emits the first element matching then completes. + /// + public static Observable Find(this Observable source, Func predicate) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + return source.Where(predicate).Take(1); + } + + /// + /// Emits the zero-based index of the first element matching then + /// completes. Completes without emitting if no match is found. + /// + public static Observable FindIndex(this Observable source, Func predicate) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + int index = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + bool found = false; + int foundIndex = 0; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (predicate(x)) + { + foundIndex = index; + found = true; + disposed = true; + } + else + { + index++; + } + } + + if (found) + { + observer.OnNext(foundIndex); + observer.OnCompleted(); + upstream?.Dispose(); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits and completes if the source completes without emitting + /// any value; otherwise forwards all values as-is. + /// + public static Observable DefaultIfEmpty(this Observable source, T defaultValue) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + hasValue = true; + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool emitDefault = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + emitDefault = r.IsSuccess && !hasValue; + } + + if (emitDefault) + { + observer.OnNext(defaultValue); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// If the source completes successfully without emitting any value, completes downstream with a + /// failure result using the provided exception factory (defaults to + /// ). + /// + public static Observable ThrowIfEmpty(this Observable source, Func? exceptionFactory = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool hasValue = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + hasValue = true; + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool throwEmpty = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + throwEmpty = r.IsSuccess && !hasValue; + } + + if (throwEmpty) + { + Exception ex = exceptionFactory?.Invoke() + ?? new InvalidOperationException("Sequence contains no elements."); + observer.OnCompleted(Result.Failure(ex)); + } + else + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the most-recent source value whenever the duration observable (produced by + /// for each source value) fires. Resets on each new source value. + /// + public static Observable Audit(this Observable source, Func> durationSelector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (durationSelector is null) + { + throw new ArgumentNullException(nameof(durationSelector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? latest = default; + bool hasLatest = false; + IDisposable? upstream = null; + IDisposable? durationSub = null; + + upstream = source.Subscribe( + x => + { + IDisposable? oldDurationSub = null; + Observable? duration = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + latest = x; + hasLatest = true; + oldDurationSub = durationSub; + durationSub = null; + duration = durationSelector(x); + } + + oldDurationSub?.Dispose(); + + IDisposable newSub = duration!.Subscribe( + _ => + { + T? toEmit = default; + bool shouldEmit = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (hasLatest) + { + toEmit = latest; + hasLatest = false; + shouldEmit = true; + } + } + + if (shouldEmit) + { + observer.OnNext(toEmit!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + _ => { }); + + using (gate.EnterScope()) + { + if (disposed) + { + newSub.Dispose(); + return; + } + + durationSub = newSub; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + IDisposable? sub = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub = durationSub; + durationSub = null; + } + + sub?.Dispose(); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + durationSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Emits the most-recent source value after each fixed window. + /// + public static Observable AuditTime(this Observable source, TimeSpan duration, TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + return source.Audit(_ => Observable.Timer(duration, tp).Select(_ => Unit.Default)); + } + + /// + /// Emits the most-recent source value whenever emits, then resets. + /// + public static Observable Sample(this Observable source, Observable sampler) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (sampler is null) + { + throw new ArgumentNullException(nameof(sampler)); + } + + return source.Sample(sampler); + } + + /// + /// Emits the most-recent source value whenever emits, then resets. + /// + public static Observable Sample(this Observable source, Observable sampler) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (sampler is null) + { + throw new ArgumentNullException(nameof(sampler)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + T? latest = default; + bool hasLatest = false; + IDisposable? upstream = null; + IDisposable? samplerSub = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + latest = x; + hasLatest = true; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + IDisposable? sub = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub = samplerSub; + samplerSub = null; + } + + sub?.Dispose(); + observer.OnCompleted(r); + }); + + samplerSub = sampler.Subscribe( + _ => + { + T? toEmit = default; + bool shouldEmit = false; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (hasLatest) + { + toEmit = latest; + hasLatest = false; + shouldEmit = true; + } + } + + if (shouldEmit) + { + observer.OnNext(toEmit!); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + IDisposable? sub = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + sub = upstream; + upstream = null; + } + + sub?.Dispose(); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + samplerSub?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Extensions/FilteringExtensions.cs b/R3Ext/Extensions/FilteringExtensions.cs index e9b71c2..85e47eb 100644 --- a/R3Ext/Extensions/FilteringExtensions.cs +++ b/R3Ext/Extensions/FilteringExtensions.cs @@ -6,7 +6,7 @@ namespace R3Ext; /// /// Filtering and conditional extensions for R3 observables. /// -public static class FilteringExtensions +public static partial class FilteringExtensions { /// /// Logical NOT for boolean streams. diff --git a/R3Ext/Extensions/FlatMapExtensions.cs b/R3Ext/Extensions/FlatMapExtensions.cs new file mode 100644 index 0000000..4df9fbe --- /dev/null +++ b/R3Ext/Extensions/FlatMapExtensions.cs @@ -0,0 +1,1014 @@ +using R3; + +namespace R3Ext; + +/// +/// Higher-order flat-mapping extensions for R3 observables. +/// +public static class FlatMapExtensions +{ + /// + /// Projects each source value to an inner observable and concatenates them sequentially, + /// waiting for each inner observable to complete before subscribing to the next. + /// + public static Observable ConcatMap( + this Observable source, + Func> selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + bool innerActive = false; + Queue> pending = new(); + IDisposable? upstream = null; + IDisposable? innerSub = null; + Result sourceResult = default; + + void SubscribeToInner(Observable inner) + { + IDisposable sub = inner.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + Observable? nextInner = null; + bool shouldComplete = false; + Result completionResult = r; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (pending.Count > 0) + { + nextInner = pending.Dequeue(); + } + else + { + innerActive = false; + if (sourceCompleted) + { + shouldComplete = true; + completionResult = sourceResult; + } + } + } + + if (nextInner is not null) + { + SubscribeToInner(nextInner); + } + else if (shouldComplete) + { + observer.OnCompleted(completionResult); + } + }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub.Dispose(); + return; + } + + innerSub = sub; + } + } + + upstream = source.Subscribe( + x => + { + Observable? toSubscribe = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + Observable inner = selector(x); + + if (!innerActive) + { + innerActive = true; + toSubscribe = inner; + } + else + { + pending.Enqueue(inner); + } + } + + if (toSubscribe is not null) + { + SubscribeToInner(toSubscribe); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + sourceResult = r; + shouldComplete = !innerActive && pending.Count == 0; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + innerSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Projects each source value to an inner observable, cancelling any previously active inner + /// observable when a new source value arrives. Only values from the most recent inner observable + /// are forwarded downstream. + /// + public static Observable SwitchMap( + this Observable source, + Func> selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + bool innerActive = false; + int innerGeneration = 0; + IDisposable? upstream = null; + IDisposable? innerSub = null; + + upstream = source.Subscribe( + x => + { + IDisposable? oldSub; + Observable inner; + int myGeneration; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + oldSub = innerSub; + innerSub = null; + innerActive = true; + myGeneration = ++innerGeneration; + inner = selector(x); + } + + oldSub?.Dispose(); + + IDisposable sub = inner.Subscribe( + v => + { + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + observer.OnNext(v); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + innerActive = false; + shouldComplete = sourceCompleted; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + sub.Dispose(); + return; + } + + innerSub = sub; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + shouldComplete = !innerActive; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + innerSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Alias for . Projects to the latest inner observable, + /// dropping earlier ones when a new source value arrives. + /// + public static Observable FlatMapLatest( + this Observable source, + Func> selector) + => source.SwitchMap(selector); + + /// + /// Projects each source value to an inner observable, but ignores new source values while an + /// inner observable is still active. Only begins a new inner subscription once the current one + /// completes. + /// + public static Observable ExhaustMap( + this Observable source, + Func> selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + bool innerActive = false; + Result sourceResult = default; + IDisposable? upstream = null; + IDisposable? innerSub = null; + + upstream = source.Subscribe( + x => + { + Observable? toSubscribe = null; + + using (gate.EnterScope()) + { + if (disposed || innerActive) + { + return; + } + + innerActive = true; + toSubscribe = selector(x); + } + + if (toSubscribe is null) + { + return; + } + + IDisposable sub = toSubscribe.Subscribe( + v => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(v); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + innerActive = false; + shouldComplete = sourceCompleted; + } + + if (shouldComplete) + { + observer.OnCompleted(sourceResult); + } + }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub.Dispose(); + return; + } + + innerSub = sub; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + sourceResult = r; + shouldComplete = !innerActive; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + innerSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Recursively projects each emitted value through the selector to produce additional values, + /// subscribing to all resulting inner observables concurrently (breadth-first expansion). + /// + public static Observable Expand(this Observable source, Func> selector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + int activeCount = 0; + IDisposable? upstream = null; + List innerSubs = new(); + + void SubscribeToInner(Observable inner) + { + IDisposable sub = inner.Subscribe( + v => + { + Observable next; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(v); + activeCount++; + next = selector(v); + } + + SubscribeToInner(next); + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + activeCount--; + shouldComplete = activeCount == 0 && sourceCompleted; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub.Dispose(); + return; + } + + innerSubs.Add(sub); + } + } + + upstream = source.Subscribe( + x => + { + Observable inner; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + activeCount++; + inner = selector(x); + } + + SubscribeToInner(inner); + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + shouldComplete = activeCount == 0; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + List subs; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + subs = innerSubs; + innerSubs = new List(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + upstream?.Dispose(); + }); + }); + } + + /// + /// Like Scan but the accumulator returns an observable. All inner observables are + /// subscribed to concurrently (merged), and each emission updates the running accumulator state. + /// + public static Observable MergeScan( + this Observable source, + TAccumulate seed, + Func> accumulator) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (accumulator is null) + { + throw new ArgumentNullException(nameof(accumulator)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + int activeInners = 0; + TAccumulate current = seed; + IDisposable? upstream = null; + List innerSubs = new(); + + upstream = source.Subscribe( + x => + { + Observable inner; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + inner = accumulator(current, x); + activeInners++; + } + + IDisposable sub = inner.Subscribe( + v => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + current = v; + observer.OnNext(v); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + activeInners--; + shouldComplete = sourceCompleted && activeInners == 0; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub.Dispose(); + return; + } + + innerSubs.Add(sub); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + shouldComplete = activeInners == 0; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + List subs; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + subs = innerSubs; + innerSubs = new List(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + upstream?.Dispose(); + }); + }); + } + + /// + /// Like MergeScan but switches to each new inner observable (cancelling the previous one) + /// rather than merging all concurrently. + /// + public static Observable SwitchScan( + this Observable source, + TAccumulate seed, + Func> accumulator) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (accumulator is null) + { + throw new ArgumentNullException(nameof(accumulator)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + bool innerActive = false; + int innerGeneration = 0; + TAccumulate current = seed; + IDisposable? upstream = null; + IDisposable? innerSub = null; + + upstream = source.Subscribe( + x => + { + IDisposable? oldSub; + Observable inner; + int myGeneration; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + oldSub = innerSub; + innerSub = null; + innerActive = true; + myGeneration = ++innerGeneration; + inner = accumulator(current, x); + } + + oldSub?.Dispose(); + + IDisposable sub = inner.Subscribe( + v => + { + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + current = v; + observer.OnNext(v); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + return; + } + + innerActive = false; + shouldComplete = sourceCompleted; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + using (gate.EnterScope()) + { + if (disposed || innerGeneration != myGeneration) + { + sub.Dispose(); + return; + } + + innerSub = sub; + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + bool shouldComplete; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + shouldComplete = !innerActive; + } + + if (shouldComplete) + { + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + innerSub?.Dispose(); + upstream?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Extensions/SideEffectExtensions.cs b/R3Ext/Extensions/SideEffectExtensions.cs new file mode 100644 index 0000000..7a051df --- /dev/null +++ b/R3Ext/Extensions/SideEffectExtensions.cs @@ -0,0 +1,354 @@ +using R3; + +namespace R3Ext; + +/// +/// Side-effect operators that observe events without transforming the stream. +/// Note: R3 already ships a general-purpose Do(onNext, onErrorResume, onCompleted) method; +/// these focused overloads provide cleaner call sites for single-event side-effects. +/// +public static class SideEffectExtensions +{ + /// + /// Execute on each OnErrorResume event without consuming the error. + /// The error continues to propagate downstream. + /// + public static Observable DoOnError(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + action(ex); + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Execute when the sequence terminates (success or failure). + /// + public static Observable DoOnComplete(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + action(r); + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Execute only when the sequence completes successfully. + /// + public static Observable DoOnComplete(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (r.IsSuccess) + { + action(); + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Execute on any terminal event (success or failure completion). + /// Does NOT fire on OnErrorResume events, which are non-terminal in R3. + /// + public static Observable DoOnTerminate(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + action(); + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Execute after the downstream has been notified of the terminal + /// event, rather than before. The action fires after OnCompleted is forwarded. + /// + public static Observable DoAfterTerminate(this Observable source, Action action) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext(x); + } + }, + observer.OnErrorResume, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + action(); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } +} diff --git a/R3Ext/Extensions/TransformationExtensions.cs b/R3Ext/Extensions/TransformationExtensions.cs new file mode 100644 index 0000000..8a31633 --- /dev/null +++ b/R3Ext/Extensions/TransformationExtensions.cs @@ -0,0 +1,172 @@ +using R3; + +namespace R3Ext; + +/// +/// Transformation extensions for R3 observables. +/// +public static class TransformationExtensions +{ + /// + /// Projects each element to a constant value, replacing all upstream values. + /// + public static Observable MapTo(this Observable source, TResult constant) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return source.Select(_ => constant); + } + + /// + /// Applies a selector to each element and filters out null results (reference type variant). + /// + public static Observable CompactMap(this Observable source, Func selector) + where TResult : class + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return source.Select(selector).Where(x => x is not null).Select(x => x!); + } + + /// + /// Applies a selector to each element and filters out null results (value type variant). + /// + public static Observable CompactMap(this Observable source, Func selector) + where TResult : struct + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return source.Select(selector).Where(x => x.HasValue).Select(x => x!.Value); + } + + /// + /// Pairs each element with its zero-based index in the sequence. + /// + public static Observable<(T Value, int Index)> WithIndex(this Observable source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return Observable.Create<(T Value, int Index)>(observer => + { + Lock gate = new(); + bool disposed = false; + int index = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnNext((x, index++)); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Applies an accumulator function over the source sequence, emitting each intermediate result. + /// Equivalent to Scan with a seed value. + /// + public static Observable RunningFold( + this Observable source, + TAccumulate seed, + Func accumulator) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (accumulator is null) + { + throw new ArgumentNullException(nameof(accumulator)); + } + + return source.Scan(seed, accumulator); + } + + /// + /// Applies an accumulator function over the source sequence without a seed, emitting each intermediate result. + /// Equivalent to Scan without a seed. + /// + public static Observable RunningReduce(this Observable source, Func accumulator) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (accumulator is null) + { + throw new ArgumentNullException(nameof(accumulator)); + } + + return source.Scan(accumulator); + } +} diff --git a/R3Ext/Subjects/AsyncSubject.cs b/R3Ext/Subjects/AsyncSubject.cs new file mode 100644 index 0000000..1fa202b --- /dev/null +++ b/R3Ext/Subjects/AsyncSubject.cs @@ -0,0 +1,149 @@ +using R3; + +namespace R3Ext; + +/// +/// A Subject that buffers the last value and emits it to all current and future subscribers only when +/// is called with a successful result. On failure, no value is emitted. +/// +public sealed class AsyncSubject : Observable, IDisposable +{ + private readonly Lock _gate = new(); + private bool _isCompleted = false; + private Result _completionResult = default; + private T? _lastValue = default; + private bool _hasValue = false; + private List> _observers = new(); + private bool _disposed = false; + + /// + /// Buffers as the latest value. Does not emit to subscribers until completion. + /// + public void OnNext(T value) + { + using (_gate.EnterScope()) + { + if (_disposed || _isCompleted) + { + return; + } + + _lastValue = value; + _hasValue = true; + } + } + + /// + /// Forwards the error to all current subscribers without stopping value buffering. + /// + public void OnErrorResume(Exception error) + { + if (error is null) + { + throw new ArgumentNullException(nameof(error)); + } + + Observer[] observers; + using (_gate.EnterScope()) + { + if (_disposed || _isCompleted) + { + return; + } + + observers = _observers.ToArray(); + } + + foreach (var obs in observers) + { + obs.OnErrorResume(error); + } + } + + /// Completes with a successful result, emitting the last buffered value (if any). + public void OnCompleted() => OnCompleted(Result.Success); + + /// Completes with the given as a failure result (no value emitted). + public void OnCompleted(Exception exception) => OnCompleted(Result.Failure(exception)); + + /// Completes with the given . + public void OnCompleted(Result result) + { + Observer[] observers; + T? lastValue; + bool hasValue; + using (_gate.EnterScope()) + { + if (_disposed || _isCompleted) + { + return; + } + + _isCompleted = true; + _completionResult = result; + lastValue = _lastValue; + hasValue = _hasValue; + observers = _observers.ToArray(); + _observers.Clear(); + } + + foreach (var obs in observers) + { + if (result.IsSuccess && hasValue) + { + obs.OnNext(lastValue!); + } + + obs.OnCompleted(result); + } + } + + /// + protected override IDisposable SubscribeCore(Observer observer) + { + using (_gate.EnterScope()) + { + if (_disposed) + { + observer.OnCompleted(Result.Failure(new ObjectDisposedException(nameof(AsyncSubject)))); + return Disposable.Empty; + } + + if (_isCompleted) + { + if (_completionResult.IsSuccess && _hasValue) + { + observer.OnNext(_lastValue!); + } + + observer.OnCompleted(_completionResult); + return Disposable.Empty; + } + + _observers.Add(observer); + } + + return Disposable.Create(() => + { + using (_gate.EnterScope()) + { + _observers.Remove(observer); + } + }); + } + + /// + public void Dispose() + { + using (_gate.EnterScope()) + { + if (_disposed) + { + return; + } + + _disposed = true; + _observers.Clear(); + } + } +} diff --git a/R3Ext/Subjects/ReadOnlySubjectWrapper.cs b/R3Ext/Subjects/ReadOnlySubjectWrapper.cs new file mode 100644 index 0000000..b59e929 --- /dev/null +++ b/R3Ext/Subjects/ReadOnlySubjectWrapper.cs @@ -0,0 +1,40 @@ +using R3; + +namespace R3Ext; + +/// +/// Wraps any to expose only the read-only observable surface, +/// hiding OnNext, OnCompleted, and other subject methods. +/// +public sealed class ReadOnlySubject : Observable +{ + private readonly Observable _inner; + + /// The source observable to wrap. + public ReadOnlySubject(Observable inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + /// + protected override IDisposable SubscribeCore(Observer observer) + => _inner.Subscribe(observer.OnNext, observer.OnErrorResume, observer.OnCompleted); +} + +/// +/// Extension methods for wrapping subjects as read-only observables. +/// +public static class SubjectExtensions +{ + /// Wraps any observable as a . + public static ReadOnlySubject AsReadOnly(this Observable source) + => new ReadOnlySubject(source); + + /// Wraps a as a . + public static ReadOnlySubject AsReadOnly(this Subject subject) + => new ReadOnlySubject(subject); + + /// Wraps a as a . + public static ReadOnlySubject AsReadOnly(this BehaviorSubject subject) + => new ReadOnlySubject(subject); +} diff --git a/R3Ext/Timing/TimingExtensions.Advanced.cs b/R3Ext/Timing/TimingExtensions.Advanced.cs new file mode 100644 index 0000000..db0f1d9 --- /dev/null +++ b/R3Ext/Timing/TimingExtensions.Advanced.cs @@ -0,0 +1,852 @@ +using R3; + +namespace R3Ext; + +public static partial class TimingExtensions +{ + /// + /// Wraps each emitted value with the elapsed time since the previous emission. + /// The first item is wrapped with . + /// + public static Observable> TimeInterval( + this Observable source, + TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + + return Observable.Create>(observer => + { + Lock gate = new(); + bool disposed = false; + DateTimeOffset? lastTime = null; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + TimeInterval item; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + DateTimeOffset now = tp.GetUtcNow(); + TimeSpan interval = lastTime.HasValue ? now - lastTime.Value : TimeSpan.Zero; + lastTime = now; + item = new TimeInterval(x, interval); + } + + observer.OnNext(item); + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + } + }); + }); + } + + /// + /// Delays each element by an amount determined by a per-element observable. + /// The element is emitted when its duration observable emits its first value. + /// Multiple elements can be in-flight simultaneously. + /// + public static Observable DelayWhen( + this Observable source, + Func> delayDurationSelector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (delayDurationSelector is null) + { + throw new ArgumentNullException(nameof(delayDurationSelector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + Result completionResult = default; + int activeInner = 0; + IDisposable? upstream = null; + List innerSubs = new(); + + void CheckComplete() + { + // Must be called under gate. + if (sourceCompleted && activeInner == 0) + { + observer.OnCompleted(completionResult); + } + } + + upstream = source.Subscribe( + x => + { + bool shouldContinue; + using (gate.EnterScope()) + { + shouldContinue = !disposed; + if (shouldContinue) + { + activeInner++; + } + } + + if (!shouldContinue) + { + return; + } + + bool innerFired = false; + IDisposable? innerSub = null; + + innerSub = delayDurationSelector(x).Subscribe( + _ => + { + bool shouldEmit; + using (gate.EnterScope()) + { + shouldEmit = !disposed && !innerFired; + if (!innerFired) + { + innerFired = true; + activeInner--; + if (innerSub is not null) + { + innerSubs.Remove(innerSub); + } + } + } + + if (shouldEmit) + { + observer.OnNext(x); + using (gate.EnterScope()) + { + CheckComplete(); + } + } + }, + ex => + { + bool wasActive; + using (gate.EnterScope()) + { + wasActive = !innerFired; + if (!innerFired) + { + innerFired = true; + activeInner--; + if (innerSub is not null) + { + innerSubs.Remove(innerSub); + } + } + } + + if (wasActive && !disposed) + { + observer.OnErrorResume(ex); + } + }, + r => + { + using (gate.EnterScope()) + { + if (!innerFired) + { + innerFired = true; + activeInner--; + if (innerSub is not null) + { + innerSubs.Remove(innerSub); + } + + CheckComplete(); + } + } + }); + + // Register the subscription for cleanup; handle the case where the + // inner observable fired synchronously (innerFired already true). + using (gate.EnterScope()) + { + if (!disposed && !innerFired && innerSub is not null) + { + innerSubs.Add(innerSub); + } + else if (!innerFired && disposed) + { + innerFired = true; + activeInner--; + innerSub?.Dispose(); + } + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + completionResult = r; + CheckComplete(); + } + }); + + return Disposable.Create(() => + { + List toDispose; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + toDispose = new List(innerSubs); + innerSubs.Clear(); + } + + upstream?.Dispose(); + foreach (IDisposable d in toDispose) + { + d.Dispose(); + } + }); + }); + } + + /// + /// Delays each element by an amount determined by a per-element observable, + /// and delays the subscription to until + /// emits its first value. + /// + public static Observable DelayWhen( + this Observable source, + Func> delayDurationSelector, + Observable subscriptionDelay) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (delayDurationSelector is null) + { + throw new ArgumentNullException(nameof(delayDurationSelector)); + } + + if (subscriptionDelay is null) + { + throw new ArgumentNullException(nameof(subscriptionDelay)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + IDisposable? delaySub = null; + IDisposable? mainSub = null; + + delaySub = subscriptionDelay.Subscribe( + _ => + { + bool shouldSubscribe; + using (gate.EnterScope()) + { + shouldSubscribe = !disposed && mainSub is null; + } + + if (!shouldSubscribe) + { + return; + } + + IDisposable inner = source.DelayWhen(delayDurationSelector).Subscribe( + v => observer.OnNext(v), + ex => observer.OnErrorResume(ex), + r => observer.OnCompleted(r)); + + using (gate.EnterScope()) + { + if (disposed) + { + inner.Dispose(); + } + else + { + mainSub = inner; + } + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed || mainSub is not null) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + } + + delaySub?.Dispose(); + mainSub?.Dispose(); + }); + }); + } + + /// + /// Allows at most items per . + /// Excess items are queued and emitted in subsequent windows. + /// + public static Observable RateLimit( + this Observable source, + int count, + TimeSpan period, + TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (period <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(period)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + bool sourceCompleted = false; + Result completionResult = default; + int emittedThisWindow = 0; + Queue queue = new(); + IDisposable? upstream = null; + ITimer? timer = null; + + void EnsureTimer() + { + if (timer is null) + { + timer = tp.CreateTimer( + _ => + { + List? toEmit = null; + bool complete = false; + Result result = default; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + emittedThisWindow = 0; + + if (queue.Count > 0) + { + toEmit = new List(); + int drainCount = Math.Min(queue.Count, count); + for (int i = 0; i < drainCount; i++) + { + toEmit.Add(queue.Dequeue()); + emittedThisWindow++; + } + } + + if (sourceCompleted && queue.Count == 0) + { + complete = true; + result = completionResult; + timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + } + + if (toEmit is not null) + { + foreach (T item in toEmit) + { + observer.OnNext(item); + } + } + + if (complete) + { + observer.OnCompleted(result); + } + }, + null, period, period); + } + } + + upstream = source.Subscribe( + x => + { + bool shouldEmit; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + EnsureTimer(); + + if (emittedThisWindow < count) + { + emittedThisWindow++; + shouldEmit = true; + } + else + { + queue.Enqueue(x); + shouldEmit = false; + } + } + + if (shouldEmit) + { + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + + observer.OnErrorResume(ex); + }, + r => + { + List? remaining = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + sourceCompleted = true; + completionResult = r; + timer?.Dispose(); + + if (queue.Count > 0) + { + remaining = queue.ToList(); + queue.Clear(); + } + } + + if (remaining is not null) + { + foreach (T item in remaining) + { + observer.OnNext(item); + } + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + timer?.Dispose(); + upstream?.Dispose(); + queue.Clear(); + } + }); + }); + } + + /// + /// Passes items through while maintaining an internal bounded buffer. + /// When the buffer is at , the configured + /// determines how overflow is handled. + /// + public static Observable BufferWithOverflow( + this Observable source, + int capacity, + OverflowStrategy strategy = OverflowStrategy.DropOldest, + TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + Queue buffer = new(); + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + bool shouldEmit = true; + Exception? overflowError = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (buffer.Count >= capacity) + { + switch (strategy) + { + case OverflowStrategy.DropOldest: + buffer.Dequeue(); + buffer.Enqueue(x); + break; + + case OverflowStrategy.DropLatest: + shouldEmit = false; + break; + + case OverflowStrategy.Error: + overflowError = new InvalidOperationException( + $"Buffer overflow: capacity {capacity} exceeded."); + shouldEmit = false; + break; + } + } + else + { + buffer.Enqueue(x); + } + } + + if (overflowError is not null) + { + observer.OnErrorResume(overflowError); + } + else if (shouldEmit) + { + observer.OnNext(x); + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + buffer.Clear(); + } + }); + }); + } + + /// + /// Emits overlapping or non-overlapping arrays of a specified size, advancing by + /// items between each window. + /// When is 0, it defaults to (non-overlapping). + /// Partial chunks at completion are discarded. + /// + public static Observable Chunked( + this Observable source, + int size, + int step = 0) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (size <= 0) + { + throw new ArgumentOutOfRangeException(nameof(size)); + } + + if (step < 0) + { + throw new ArgumentOutOfRangeException(nameof(step)); + } + + int effectiveStep = step == 0 ? size : step; + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + List> openChunks = new(); + int itemCount = 0; + IDisposable? upstream = null; + + upstream = source.Subscribe( + x => + { + List? toEmit = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + if (itemCount % effectiveStep == 0) + { + openChunks.Add(new List()); + } + + foreach (List chunk in openChunks) + { + chunk.Add(x); + } + + itemCount++; + + // Collect completed chunks back-to-front to allow safe RemoveAt, + // then reverse so they are emitted oldest-first. + for (int i = openChunks.Count - 1; i >= 0; i--) + { + if (openChunks[i].Count >= size) + { + toEmit ??= new List(); + toEmit.Add(openChunks[i].ToArray()); + openChunks.RemoveAt(i); + } + } + + toEmit?.Reverse(); + } + + if (toEmit is not null) + { + foreach (T[] chunk in toEmit) + { + observer.OnNext(chunk); + } + } + }, + ex => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + } + + observer.OnErrorResume(ex); + }, + r => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + openChunks.Clear(); + observer.OnCompleted(r); + } + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + openChunks.Clear(); + } + }); + }); + } +} + +/// +/// Represents a value paired with the time interval since the previous emission. +/// +/// The type of value. +public readonly struct TimeInterval +{ + public T Value { get; } + + public TimeSpan Interval { get; } + + public TimeInterval(T value, TimeSpan interval) + { + Value = value; + Interval = interval; + } + + public void Deconstruct(out T value, out TimeSpan interval) + { + value = Value; + interval = Interval; + } +} + +/// +/// Defines strategies for handling buffer overflow. +/// +public enum OverflowStrategy +{ + DropOldest, + DropLatest, + Error, +} diff --git a/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs b/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs new file mode 100644 index 0000000..ad02612 --- /dev/null +++ b/R3Ext/Timing/TimingExtensions.BufferAdvanced.cs @@ -0,0 +1,365 @@ +#pragma warning disable SA1107, SA1124, SA1501, SA1503, SA1515, SA1025 +using R3; + +namespace R3Ext; + +public static partial class TimingExtensions +{ + /// + /// Collects source items into buffers that start when emits and + /// close when the corresponding observable returned by emits. + /// Multiple buffers may be open simultaneously. + /// + public static Observable BufferToggle( + this Observable source, + Observable openings, + Func> closingSelector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (openings is null) + { + throw new ArgumentNullException(nameof(openings)); + } + + if (closingSelector is null) + { + throw new ArgumentNullException(nameof(closingSelector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + + // ClosingSub may be null while we're between AddBuffer and the Subscribe return + List<(List Buffer, IDisposable? ClosingSub)> openBuffers = new(); + IDisposable? sourceSub = null; + IDisposable? openingsSub = null; + + openingsSub = openings.Subscribe( + opening => + { + List newBuffer = new(); + + // Add buffer before subscribing to the closer so it is visible to the close + // callback even when the closer fires synchronously. + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + openBuffers.Add((newBuffer, null)); + } + + IDisposable closingSub = closingSelector(opening).Take(1).Subscribe( + _ => + { + T[]? toEmit = null; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + int idx = openBuffers.FindIndex(b => ReferenceEquals(b.Buffer, newBuffer)); + if (idx >= 0) + { + toEmit = openBuffers[idx].Buffer.ToArray(); + openBuffers.RemoveAt(idx); + } + } + + if (toEmit is not null) + { + observer.OnNext(toEmit); + } + }, + ex => observer.OnErrorResume(ex), + _ => { }); + + // Store the real closing subscription, or dispose immediately if already closed + using (gate.EnterScope()) + { + if (disposed) + { + closingSub.Dispose(); + return; + } + + int idx = openBuffers.FindIndex(b => ReferenceEquals(b.Buffer, newBuffer)); + if (idx >= 0) + { + openBuffers[idx] = (openBuffers[idx].Buffer, closingSub); + } + else + { + // Closed synchronously before we could store the sub; dispose it now + closingSub.Dispose(); + } + } + }, + ex => + { + IDisposable[]? subs; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + subs = openBuffers.Select(b => b.ClosingSub).Where(s => s is not null).ToArray()!; + openBuffers.Clear(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + observer.OnErrorResume(ex); + }, + _ => { }); // openings completing does not close the outer sequence + + sourceSub = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + foreach (var (buf, _) in openBuffers) + { + buf.Add(x); + } + } + }, + ex => + { + T[][]? toEmit; + IDisposable[]? subs; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + toEmit = openBuffers.Select(b => b.Buffer.ToArray()).ToArray(); + subs = openBuffers.Select(b => b.ClosingSub).Where(s => s is not null).ToArray()!; + openBuffers.Clear(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + foreach (T[] arr in toEmit) + { + observer.OnNext(arr); + } + + observer.OnErrorResume(ex); + }, + r => + { + T[][]? toEmit; + IDisposable[]? subs; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + toEmit = openBuffers.Select(b => b.Buffer.ToArray()).ToArray(); + subs = openBuffers.Select(b => b.ClosingSub).Where(s => s is not null).ToArray()!; + openBuffers.Clear(); + } + + foreach (IDisposable s in subs) + { + s.Dispose(); + } + + foreach (T[] arr in toEmit) + { + observer.OnNext(arr); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + openingsSub?.Dispose(); + sourceSub?.Dispose(); + + foreach (var (_, sub) in openBuffers) + { + sub?.Dispose(); + } + + openBuffers.Clear(); + } + }); + }); + } + + /// + /// Collects source items into a single buffer. When the observable returned by + /// emits, the current buffer is emitted and a fresh + /// buffer is started with a new invocation of . + /// + public static Observable BufferWhen( + this Observable source, + Func> closingSelector) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (closingSelector is null) + { + throw new ArgumentNullException(nameof(closingSelector)); + } + + return Observable.Create(observer => + { + Lock gate = new(); + bool disposed = false; + List buffer = new(); + IDisposable? sourceSub = null; + IDisposable? closingSub = null; + + void SubscribeToCloser() + { + IDisposable? sub = null; + sub = closingSelector().Take(1).Subscribe( + _ => + { + T[] toEmit; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + toEmit = buffer.ToArray(); + buffer.Clear(); + } + + observer.OnNext(toEmit); + + // Subscribe to the next closer outside the lock + SubscribeToCloser(); + }, + ex => observer.OnErrorResume(ex), + _ => { }); + + using (gate.EnterScope()) + { + if (disposed) + { + sub?.Dispose(); + } + else + { + closingSub = sub; + } + } + } + + SubscribeToCloser(); + + sourceSub = source.Subscribe( + x => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + buffer.Add(x); + } + }, + ex => + { + T[] toEmit; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + closingSub?.Dispose(); + closingSub = null; + toEmit = buffer.ToArray(); + buffer.Clear(); + } + + observer.OnNext(toEmit); + observer.OnErrorResume(ex); + }, + r => + { + T[] toEmit; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + closingSub?.Dispose(); + closingSub = null; + toEmit = buffer.ToArray(); + buffer.Clear(); + } + + observer.OnNext(toEmit); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + closingSub?.Dispose(); + sourceSub?.Dispose(); + buffer.Clear(); + } + }); + }); + } +} diff --git a/R3Ext/Timing/TimingExtensions.Window.cs b/R3Ext/Timing/TimingExtensions.Window.cs new file mode 100644 index 0000000..c2c9a23 --- /dev/null +++ b/R3Ext/Timing/TimingExtensions.Window.cs @@ -0,0 +1,451 @@ +using R3; + +namespace R3Ext; + +public static partial class TimingExtensions +{ + /// + /// Projects each element of an observable sequence into zero or more windows, each containing + /// elements. When is less than + /// , windows overlap; when equal (the default), they are contiguous. + /// + public static Observable> WindowCount(this Observable source, int count, int skip = 0) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + if (skip < 0) + { + throw new ArgumentOutOfRangeException(nameof(skip)); + } + + if (skip == 0) + { + skip = count; + } + + return Observable.Create>(observer => + { + Lock gate = new(); + bool disposed = false; + List<(Subject Subject, int Count)> openWindows = new(); + int totalCount = 0; + IDisposable? upstream = null; + + Subject firstSubject = new(); + openWindows.Add((firstSubject, 0)); + observer.OnNext(firstSubject); + + upstream = source.Subscribe( + x => + { + Subject? newWindowSubject = null; + List>? toNotify = null; + List>? toComplete = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + // Open a new window at every skip-th item (skipping the first which opened on subscribe) + if (totalCount > 0 && totalCount % skip == 0) + { + newWindowSubject = new Subject(); + openWindows.Add((newWindowSubject, 0)); + } + + // Snapshot all open windows for notification + toNotify = new List>(openWindows.Count); + for (int i = 0; i < openWindows.Count; i++) + { + toNotify.Add(openWindows[i].Subject); + } + + // Update counts and collect windows that are now full + List>? closing = null; + for (int i = openWindows.Count - 1; i >= 0; i--) + { + var (subj, cnt) = openWindows[i]; + int newCnt = cnt + 1; + if (newCnt >= count) + { + closing ??= new List>(); + closing.Add(subj); + openWindows.RemoveAt(i); + } + else + { + openWindows[i] = (subj, newCnt); + } + } + + toComplete = closing; + totalCount++; + } + + // Emit new window first so downstream can subscribe before it receives any value + if (newWindowSubject is not null) + { + observer.OnNext(newWindowSubject); + } + + // Deliver value to all open windows (including the newly opened one) + foreach (Subject w in toNotify!) + { + w.OnNext(x); + } + + // Complete windows that have reached their capacity + if (toComplete is not null) + { + foreach (Subject w in toComplete) + { + w.OnCompleted(); + } + } + }, + ex => + { + Subject[]? windows; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + windows = openWindows.Select(w => w.Subject).ToArray(); + openWindows.Clear(); + } + + foreach (Subject w in windows) + { + w.OnCompleted(); + } + + observer.OnErrorResume(ex); + }, + r => + { + Subject[]? windows; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + windows = openWindows.Select(w => w.Subject).ToArray(); + openWindows.Clear(); + } + + foreach (Subject w in windows) + { + w.OnCompleted(); + } + + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + upstream?.Dispose(); + openWindows.Clear(); + } + }); + }); + } + + /// + /// Projects each element of an observable sequence into consecutive non-overlapping windows + /// that are produced based on timing information. A new window is opened every + /// interval and the previous window is completed. + /// + public static Observable> WindowTime(this Observable source, TimeSpan timeSpan, TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (timeSpan <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeSpan)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + + return Observable.Create>(observer => + { + Lock gate = new(); + bool disposed = false; + Subject? currentWindow = null; + IDisposable? upstream = null; + ITimer? timer = null; + + // Open the first window immediately + currentWindow = new Subject(); + observer.OnNext(currentWindow); + + timer = tp.CreateTimer( + _ => + { + Subject? windowToComplete; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + windowToComplete = currentWindow; + Subject newWindow = new(); + currentWindow = newWindow; + + // Emit new window inside the lock so no source item can slip into the + // new window before a downstream subscriber has had a chance to subscribe. + observer.OnNext(newWindow); + timer!.Change(timeSpan, Timeout.InfiniteTimeSpan); + } + + // Complete the old window after releasing the lock + windowToComplete?.OnCompleted(); + }, + null, timeSpan, Timeout.InfiniteTimeSpan); + + upstream = source.Subscribe( + x => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + window = currentWindow; + } + + window?.OnNext(x); + }, + ex => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Dispose(); + window = currentWindow; + currentWindow = null; + } + + window?.OnCompleted(); + observer.OnErrorResume(ex); + }, + r => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Dispose(); + window = currentWindow; + currentWindow = null; + } + + window?.OnCompleted(); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + timer?.Dispose(); + upstream?.Dispose(); + currentWindow = null; + } + }); + }); + } + + /// + /// Projects each element into consecutive windows that close when either + /// elapses or elements have been + /// collected โ€” whichever comes first. + /// + public static Observable> WindowTime(this Observable source, TimeSpan timeSpan, int maxCount, TimeProvider? timeProvider = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (timeSpan <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeSpan)); + } + + if (maxCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxCount)); + } + + TimeProvider tp = timeProvider ?? ObservableSystem.DefaultTimeProvider; + + return Observable.Create>(observer => + { + Lock gate = new(); + bool disposed = false; + Subject? currentWindow = null; + int windowItemCount = 0; + IDisposable? upstream = null; + ITimer? timer = null; + + void RollWindow(out Subject? completed) + { + completed = currentWindow; + Subject newWindow = new(); + currentWindow = newWindow; + windowItemCount = 0; + observer.OnNext(newWindow); + timer!.Change(timeSpan, Timeout.InfiniteTimeSpan); + } + + currentWindow = new Subject(); + observer.OnNext(currentWindow); + + timer = tp.CreateTimer( + _ => + { + Subject? windowToComplete; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + RollWindow(out windowToComplete); + } + + windowToComplete?.OnCompleted(); + }, + null, timeSpan, Timeout.InfiniteTimeSpan); + + upstream = source.Subscribe( + x => + { + Subject? windowForItem; + Subject? windowToComplete = null; + + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + windowForItem = currentWindow; + windowItemCount++; + + if (windowItemCount >= maxCount) + { + RollWindow(out windowToComplete); + } + } + + // Deliver item to the window that was current when it arrived + windowForItem?.OnNext(x); + + // Close the count-saturated window after delivering its last item + windowToComplete?.OnCompleted(); + }, + ex => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Dispose(); + window = currentWindow; + currentWindow = null; + } + + window?.OnCompleted(); + observer.OnErrorResume(ex); + }, + r => + { + Subject? window; + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + timer?.Dispose(); + window = currentWindow; + currentWindow = null; + } + + window?.OnCompleted(); + observer.OnCompleted(r); + }); + + return Disposable.Create(() => + { + using (gate.EnterScope()) + { + if (disposed) + { + return; + } + + disposed = true; + timer?.Dispose(); + upstream?.Dispose(); + currentWindow = null; + } + }); + }); + } +} diff --git a/README.md b/README.md index fbad484..9fbbfc7 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ source.BindOneWay( - **UnsafeAccessor**: Access internal/private members without reflection (NET8.0+) - **Zero Runtime Cost**: All binding code generated at compile-time -### ๐Ÿ“Š Reactive Collections (R3.DynamicData) +### ๐Ÿ“Š Reactive Collections (R3Ext.DynamicData) High-performance observable collections with rich transformation operators, ported from DynamicData: @@ -319,7 +319,7 @@ dotnet add package R3Ext dotnet add package R3Ext.Bindings.SourceGenerator # Reactive collections (DynamicData port) -dotnet add package R3.DynamicData +dotnet add package R3Ext.DynamicData # .NET MAUI integration (optional, for MAUI apps) dotnet add package R3Ext.Bindings.MauiTargets @@ -479,7 +479,7 @@ R3Ext/ โ”‚ โ”‚ โ””โ”€โ”€ Interaction.cs # View-ViewModel communication โ”‚ โ””โ”€โ”€ RxObject.cs # MVVM base class โ”‚ -โ”œโ”€โ”€ R3.DynamicData/ # Reactive collections (NEW!) +โ”œโ”€โ”€ R3Ext.DynamicData/ # Reactive collections (NEW!) โ”‚ โ”œโ”€โ”€ List/ # Observable list operators โ”‚ โ”œโ”€โ”€ Cache/ # Observable cache operators โ”‚ โ”œโ”€โ”€ Operators/ # Transformation operators @@ -493,7 +493,7 @@ R3Ext/ โ”‚ โ””โ”€โ”€ GenerateUiBindingTargetsTask.cs # MSBuild task for UI bindings โ”‚ โ”œโ”€โ”€ R3Ext.Tests/ # Core library tests -โ”œโ”€โ”€ R3.DynamicData.Tests/ # DynamicData tests (NEW!) +โ”œโ”€โ”€ R3Ext.DynamicData.Tests/ # DynamicData tests (NEW!) โ””โ”€โ”€ R3Ext.SampleApp/ # .NET MAUI sample app ``` diff --git a/docs/ClosureEliminationPlan.md b/docs/ClosureEliminationPlan.md index cec9b19..a9861ae 100644 --- a/docs/ClosureEliminationPlan.md +++ b/docs/ClosureEliminationPlan.md @@ -1,4 +1,4 @@ -# Closure Elimination Plan for R3.DynamicData +# Closure Elimination Plan for R3Ext.DynamicData ## Current Status (Updated: November 23, 2025) @@ -10,7 +10,7 @@ ## Overview -This document outlines the strategy to eliminate closures in the R3.DynamicData codebase by leveraging R3's closure-free overloads: `Observable.Create`, `Observable.Select`, and `Observable.Subscribe`. +This document outlines the strategy to eliminate closures in the R3Ext.DynamicData codebase by leveraging R3's closure-free overloads: `Observable.Create`, `Observable.Select`, and `Observable.Subscribe`. ## R3 Closure-Free Patterns @@ -327,7 +327,7 @@ These weren't fully enumerated in the original plan but have significant closure ### Code Structure -1. **Create Utility Types** (in R3.DynamicData/Utilities/): +1. **Create Utility Types** (in R3Ext.DynamicData/Utilities/): - `RefInt.cs`: Mutable int wrapper (sealed class for reference semantics) - `RefBool.cs`: Mutable bool wrapper (sealed class for reference semantics) diff --git a/docs/ClosureEliminationStatus.md b/docs/ClosureEliminationStatus.md index 3b83d6a..9ac7fdd 100644 --- a/docs/ClosureEliminationStatus.md +++ b/docs/ClosureEliminationStatus.md @@ -6,7 +6,7 @@ ## Executive Summary -We've successfully eliminated closures in 18 operators across the R3.DynamicData codebase, establishing clear patterns for readonly struct and sealed class state containers. All 285 tests continue to pass. Approximately **32+ operators with closures remain**. +We've successfully eliminated closures in 18 operators across the R3Ext.DynamicData codebase, establishing clear patterns for readonly struct and sealed class state containers. All 285 tests continue to pass. Approximately **32+ operators with closures remain**. ## Completed Work (18 Operators) diff --git a/docs/LibraryParity.md b/docs/LibraryParity.md index 7d86d32..240a719 100644 --- a/docs/LibraryParity.md +++ b/docs/LibraryParity.md @@ -35,9 +35,9 @@ R3Ext borrows from, ports, or depends on three upstream libraries. This document ## Component Mapping -### R3.DynamicData โ† DynamicData +### R3Ext.DynamicData โ† DynamicData -The `R3.DynamicData` project is a full port of [DynamicData](https://github.com/reactivemarbles/DynamicData) (by Roland Pheasant / ReactiveMarbles) from `System.Reactive` to R3. The port was performed in November 2025 against DynamicData ~9.0.x. +The `R3Ext.DynamicData` project is a full port of [DynamicData](https://github.com/reactivemarbles/DynamicData) (by Roland Pheasant / ReactiveMarbles) from `System.Reactive` to R3. The port was performed in November 2025 against DynamicData ~9.0.x. **Key architectural differences from upstream:** - Uses R3 `Observable` and `Observer` instead of `System.Reactive` equivalents. @@ -47,9 +47,9 @@ The `R3.DynamicData` project is a full port of [DynamicData](https://github.com/ **Namespace / Class Mapping:** -| DynamicData (upstream) | R3.DynamicData (ours) | Notes | +| DynamicData (upstream) | R3Ext.DynamicData (ours) | Notes | |------------------------|-----------------------|-------| -| `DynamicData` | `R3.DynamicData` | Root namespace | +| `DynamicData` | `R3Ext.DynamicData` | Root namespace | | `SourceCache` | `SourceCache` | Full parity | | `SourceList` | `SourceList` | Full parity | | `ObservableCacheEx` | `ObservableCacheEx` | Extension methods; split into per-operator files | @@ -65,7 +65,7 @@ The `R3.DynamicData` project is a full port of [DynamicData](https://github.com/ | `AsyncDisposeMany` | โœ… Ported | See `UpstreamChangesReview.md` ยง2 | | `FilterOnProperty` (obsolete) | Deferred | Superseded upstream; use `AutoRefresh + Filter` | -**File layout in `R3.DynamicData/`:** +**File layout in `R3Ext.DynamicData/`:** ``` Cache/ @@ -141,7 +141,7 @@ R3Ext takes a **direct NuGet dependency** on [R3](https://github.com/Cysharp/R3) 3. **For R3**: Check if the NuGet version needs bumping. Scan release notes for breaking changes or new operators that overlap with R3Ext's extension operators. 4. **For DynamicData**: Compare the release notes from the "Last Upstream Synced Against" version through the latest. For each change: - - Bug fixes โ†’ Audit the equivalent operator in `R3.DynamicData/` and fix if the same bug exists. + - Bug fixes โ†’ Audit the equivalent operator in `R3Ext.DynamicData/` and fix if the same bug exists. - New operators โ†’ Evaluate whether to port; add to `docs/MigrationMatrix.md` if accepted. - Performance improvements โ†’ Assess for adoption. diff --git a/docs/UpstreamChangesReview.md b/docs/UpstreamChangesReview.md index d6e26d8..ff24767 100644 --- a/docs/UpstreamChangesReview.md +++ b/docs/UpstreamChangesReview.md @@ -22,7 +22,7 @@ --- -## Section 1 โ€” R3.DynamicData Port: Bug Fixes +## Section 1 โ€” R3Ext.DynamicData Port: Bug Fixes These are bugs fixed in DynamicData that may have equivalent issues in our ported operators. Each item should be investigated and fixed if the same defect exists locally. @@ -34,7 +34,7 @@ These are bugs fixed in DynamicData that may have equivalent issues in our porte - [x] ๐Ÿ”ด **[DD 9.4.31 #1063] List Filter โ€” Refresh Change Support and Ordering Preservation** _Type: Bug Fix_ - The static list `.Filter()` operator was rewritten to properly support `Refresh` changeset reasons and to preserve item ordering for downstream consumers. Audit `R3.DynamicData/List/Internal/Filter.cs` against these requirements and add Refresh-specific tests. + The static list `.Filter()` operator was rewritten to properly support `Refresh` changeset reasons and to preserve item ordering for downstream consumers. Audit `R3Ext.DynamicData/List/Internal/Filter.cs` against these requirements and add Refresh-specific tests. _Upstream PR_: https://github.com/reactivemarbles/DynamicData/pull/1063 _(fixed: `List/Internal/Filter.cs` updated to handle `Refresh` change reasons and maintain stable ordering; Refresh-specific tests added.)_ @@ -100,7 +100,7 @@ These are bugs fixed in DynamicData that may have equivalent issues in our porte --- -## Section 2 โ€” R3.DynamicData Port: New Operators +## Section 2 โ€” R3Ext.DynamicData Port: New Operators These are new operators added to DynamicData after the initial port that are not currently in our migration matrix. @@ -108,7 +108,7 @@ These are new operators added to DynamicData after the initial port that are not _Type: New Operator_ DynamicData added `.AsyncDisposeMany()`, equivalent to `.DisposeMany()` but for items implementing `IAsyncDisposable`. This operator does not exist in our port and should be added to both Cache and List variants. _Upstream PR_: https://github.com/reactivemarbles/DynamicData/pull/1011 - _File to add_: `R3.DynamicData/Cache/ObservableCacheEx.AsyncDisposeMany.cs`, `R3.DynamicData/List/ObservableListEx.cs` + _File to add_: `R3Ext.DynamicData/Cache/ObservableCacheEx.AsyncDisposeMany.cs`, `R3Ext.DynamicData/List/ObservableListEx.cs` _(implemented: Cache and List variants added; IAsyncDisposable support with fire-and-forget disposal; 9 tests passing.)_ - [x] ๐ŸŸก **[DD 9.4.1 #1008] TransformOnObservable โ€” New Cache Operator (with ordering)** @@ -116,7 +116,7 @@ These are new operators added to DynamicData after the initial port that are not DynamicData has a `TransformOnObservable` Cache operator (transforms each item via an observable, preserving changeset ordering). This operator is not in our migration matrix or codebase. Assess whether it warrants porting. _Upstream source_: `src/DynamicData/Cache/Internal/TransformOnObservable.cs` _Upstream PR_: https://github.com/reactivemarbles/DynamicData/pull/1008 - _File to add_: `R3.DynamicData/Cache/Internal/TransformOnObservable.cs` + _File to add_: `R3Ext.DynamicData/Cache/Internal/TransformOnObservable.cs` _(implemented: Cache operator added with ordering preservation; 5 tests passing.)_ - [x] ๐Ÿ”ต **[DD 9.1.1 #941] Filter โ€” Predicate State Stream Overloads** @@ -127,7 +127,7 @@ These are new operators added to DynamicData after the initial port that are not --- -## Section 3 โ€” R3.DynamicData Port: Performance and Correctness Improvements +## Section 3 โ€” R3Ext.DynamicData Port: Performance and Correctness Improvements Improvements that don't introduce new APIs but improve the behavior or performance of existing operators. @@ -157,7 +157,7 @@ Improvements that don't introduce new APIs but improve the behavior or performan --- -## Section 4 โ€” R3.DynamicData Port: MigrationMatrix Updates +## Section 4 โ€” R3Ext.DynamicData Port: MigrationMatrix Updates These items update the existing migration tracking matrix in `docs/MigrationMatrix.md`. @@ -277,7 +277,7 @@ These items are from `docs/ClosureEliminationStatus.md` and `docs/MigrationMatri ## Section 9 โ€” Infrastructure / Housekeeping - [ ] ๐Ÿ”ต **[DD 9.4.31] .NET 10 Target Framework Support** - DynamicData added a `net10.0` target. Track .NET 10 availability and add it to R3.DynamicData and R3Ext target frameworks when it reaches GA. + DynamicData added a `net10.0` target. Track .NET 10 availability and add it to R3Ext.DynamicData and R3Ext target frameworks when it reaches GA. - [ ] ๐Ÿ”ต **[DD 9.3.1] Verify Net 9.0 Test Package Alignment** DynamicData 9.3.1 upgraded test packages for .NET 9.0 compatibility. Verify our `Microsoft.NET.Test.Sdk`, `xunit`, and coverage packages are at current stable versions (upstream now uses `18.x` SDK and `xunit` v3).