diff --git a/.changeset/app-998-assistant-issue-texts.md b/.changeset/app-998-assistant-issue-texts.md new file mode 100644 index 0000000000..cd600ed2e4 --- /dev/null +++ b/.changeset/app-998-assistant-issue-texts.md @@ -0,0 +1,5 @@ +--- +"@aragon/assistant": patch +--- + +Move the static Linear ticket texts into chat/prompts/issueTexts.ts so all service copy lives under chat/prompts/ diff --git a/.changeset/app-998-chat-widget-copy-and-escape-hatches.md b/.changeset/app-998-chat-widget-copy-and-escape-hatches.md new file mode 100644 index 0000000000..5a36e5894d --- /dev/null +++ b/.changeset/app-998-chat-widget-copy-and-escape-hatches.md @@ -0,0 +1,5 @@ +--- +"@aragon/assistant-chat": minor +--- + +Chat escape hatches point to support@aragon.org instead of the support portal; all widget copy is centralized in a single copy module diff --git a/.changeset/app-998-fixed-replies-prepare-ticket.md b/.changeset/app-998-fixed-replies-prepare-ticket.md new file mode 100644 index 0000000000..347011fcfe --- /dev/null +++ b/.changeset/app-998-fixed-replies-prepare-ticket.md @@ -0,0 +1,5 @@ +--- +"@aragon/assistant": patch +--- + +Turn/size-limit fixed replies point users to the actual "Prepare ticket" button instead of the removed "Create ticket" diff --git a/.changeset/app-998-support-chat-side-panel.md b/.changeset/app-998-support-chat-side-panel.md new file mode 100644 index 0000000000..486471d84a --- /dev/null +++ b/.changeset/app-998-support-chat-side-panel.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Support chat opens as a side panel the layout resizes around (toggled from the navigation bar) instead of a blur overlay diff --git a/.github/actions/changeset-version/action.yml b/.github/actions/changeset-version/action.yml index cbb7ca8aca..6d5ef32db8 100644 --- a/.github/actions/changeset-version/action.yml +++ b/.github/actions/changeset-version/action.yml @@ -1,9 +1,9 @@ name: Changeset Version -description: Runs `pnpm changeset version` scoped to the given packages and formats the changed CHANGELOG files (idempotent when no changesets). +description: Runs `pnpm changeset version` scoped to a release scope from .github/release-scopes.yml and formats the changed CHANGELOG files (idempotent when no changesets). inputs: scope: - description: Space-separated package names this flow versions; all other workspace packages are passed to changesets as --ignore. Empty = version everything. + description: Name of a release scope in .github/release-scopes.yml; all workspace packages outside that scope are passed to changesets as --ignore. Empty = version everything. required: false default: "" prettier_changelog: @@ -25,12 +25,14 @@ runs: PRETTIER_CHANGELOG: ${{ inputs.prettier_changelog }} run: | set -euo pipefail - # Workflows declare the packages they own (scope); the inversion into changesets - # --ignore flags happens here, in one place, so flows stay declarative. + # Workflows name the release scope they own; the central mapper resolves it to packages + # and the inversion into changesets --ignore flags happens here, in one place, so flows + # stay declarative. IGNORE_FLAGS=() if [ -n "$SCOPE" ]; then + SCOPE_PACKAGES=$(node -e "console.log(require('./.github/workflows/scripts/releaseScopes.js').resolveReleaseScope(process.argv[1]).join(' '))" "$SCOPE") while IFS= read -r PACKAGE; do - case " $SCOPE " in + case " $SCOPE_PACKAGES " in *" $PACKAGE "*) ;; *) IGNORE_FLAGS+=(--ignore "$PACKAGE") ;; esac diff --git a/.github/actions/generate-version-summary/action.yml b/.github/actions/generate-version-summary/action.yml new file mode 100644 index 0000000000..0990124547 --- /dev/null +++ b/.github/actions/generate-version-summary/action.yml @@ -0,0 +1,23 @@ +name: Generate Version Summary +description: Generates a per-package bump summary (new version + changelog section) for the scoped packages; run after `changeset version`, before committing. + +inputs: + scope: + description: Name of a release scope in .github/release-scopes.yml whose packages are inspected for uncommitted version bumps + required: true + +outputs: + summary: + description: Generated summary markdown (one section per bumped package) + value: ${{ steps.summary.outputs.summary }} + +runs: + using: composite + steps: + - id: summary + shell: bash + env: + SCOPE: ${{ inputs.scope }} + run: | + set -euo pipefail + node .github/workflows/scripts/generateVersionSummary.js diff --git a/.github/actions/read-changelog/action.yml b/.github/actions/read-changelog/action.yml index 3ed88a1ecb..ccafb40950 100644 --- a/.github/actions/read-changelog/action.yml +++ b/.github/actions/read-changelog/action.yml @@ -25,6 +25,6 @@ runs: path: ${{ inputs.path }} with: script: | - const readChangelog = require('./.github/workflows/scripts/readChangelog.js'); + const { readChangelog } = require('./.github/workflows/scripts/readChangelog.js'); await readChangelog({ github, context, core }); diff --git a/.github/filters.yml b/.github/filters.yml index 1d4f1b2522..4019c61489 100644 --- a/.github/filters.yml +++ b/.github/filters.yml @@ -27,7 +27,6 @@ app: assistant: - "apps/assistant/**" - "packages/assistant-contracts/**" - - "packages/assistant-chat/**" - ".github/workflows/assistant-*.yml" - ".github/workflows/shared-deploy.yml" - ".github/filters.yml" diff --git a/.github/release-scopes.yml b/.github/release-scopes.yml new file mode 100644 index 0000000000..a599797c28 --- /dev/null +++ b/.github/release-scopes.yml @@ -0,0 +1,20 @@ +# Central package→release-scope mapper: each scope lists the packages one release flow versions +# together (the `scope` input of the changeset-version action names an entry here). A changeset +# must never mix packages from different scopes — `changeset version --ignore` refuses mixed +# changesets, which breaks every scoped flow. CI enforces this via `pnpm validate:changesets`. +# When a new workspace lands, add it to a scope HERE — flows and validation read this file. +# +# A package belongs to the scope whose release deploys it: @aragon/assistant-chat ships inside +# the app bundle (only @aragon/app consumes it), so the app release consumes its changesets and +# the tag that deploys the widget is the one that versions it. @aragon/assistant-contracts is +# genuinely shared (bundled by both the app and the assistant service) and can live in only one +# scope; it stays with its domain owner, the assistant — an app deploy may therefore ship +# contracts changes whose changeset is consumed by the next assistant release. + +app: + - "@aragon/app" + - "@aragon/assistant-chat" + +assistant: + - "@aragon/assistant" + - "@aragon/assistant-contracts" diff --git a/.github/workflows/app-release-start.yml b/.github/workflows/app-release-start.yml index 9b0c0cd266..e9399fa7fe 100644 --- a/.github/workflows/app-release-start.yml +++ b/.github/workflows/app-release-start.yml @@ -74,13 +74,13 @@ jobs: path_filter: "app" linear_api_token: ${{ steps.load-secrets.outputs.LINEAR_API_TOKEN }} - # Per-package release model: this flow versions only @aragon/app; other workspaces are - # versioned by their own release flows (e.g. assistant-release-version.yml), which - # symmetrically scope to their packages. + # Per-package release model: this flow owns the "app" release scope (see + # .github/release-scopes.yml); other workspaces are versioned by their own release flows + # (e.g. assistant-release-start.yml), which symmetrically own their scopes. - name: Update version and changelog uses: ./.github/actions/changeset-version with: - scope: "@aragon/app" + scope: app prettier_changelog: "true" github_token: ${{ steps.load-secrets.outputs.ARABOT_PAT }} diff --git a/.github/workflows/assistant-release-finalize.yml b/.github/workflows/assistant-release-pr-finalize.yml similarity index 79% rename from .github/workflows/assistant-release-finalize.yml rename to .github/workflows/assistant-release-pr-finalize.yml index b3f3c55511..29903b862c 100644 --- a/.github/workflows/assistant-release-finalize.yml +++ b/.github/workflows/assistant-release-pr-finalize.yml @@ -1,9 +1,11 @@ -# The "Assistant Release Finalize" workflow runs when the "Version Packages: assistant" PR merges: -# it tags the merged commit with the changesets-native per-package tag (@aragon/assistant@x.y.z) and -# creates the GitHub Release with the changelog as notes. Publishing the release triggers the -# production deploy (assistant-production.yml). +# The "Assistant Release PR Finalize" workflow runs when a release/assistant/* PR merges: it tags +# the merge commit with the changesets-native per-package tag (@aragon/assistant@x.y.z) and creates +# the GitHub Release with the changelog as notes. Publishing the release triggers the production +# deploy (assistant-production.yml). Unlike the app flow (which tags the staging-tested head SHA), +# the merge commit is tagged here: the assistant release PR has no staging ceremony, and production +# builds from the tag, which must include the merge. -name: Assistant Release Finalize +name: Assistant Release PR Finalize on: pull_request: @@ -22,7 +24,7 @@ jobs: finalize: if: > github.event.pull_request.merged == true && - github.event.pull_request.head.ref == 'changeset-release/assistant' + startsWith(github.event.pull_request.head.ref, 'release/assistant/') runs-on: ubuntu-latest steps: - name: Load secrets diff --git a/.github/workflows/assistant-release-start.yml b/.github/workflows/assistant-release-start.yml new file mode 100644 index 0000000000..602311d018 --- /dev/null +++ b/.github/workflows/assistant-release-start.yml @@ -0,0 +1,123 @@ +# The "Assistant Release Start" workflow prepares the assistant release pull request ON DEMAND +# (trunk-based: merging to main never starts a release by itself). Dispatching it applies +# `changeset version` scoped to the assistant domain packages, pushes the bump to a timestamped +# release/assistant/* branch and opens a "Release @aragon/assistant@x.y.z" PR whose description +# lists every bumped package with its new changelog. Merging that PR is the release act — the +# finalize workflow (assistant-release-pr-finalize.yml) tags the merge commit and the tag triggers +# the production deploy. + +name: Assistant Release Start + +on: + workflow_dispatch: + inputs: + base_commit: + description: 'Commit SHA to start release from (default: main HEAD)' + required: false + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + prepare-release: + runs-on: ubuntu-latest + steps: + - name: Load secrets + id: load-secrets + uses: 1password/load-secrets-action@3a12b0ab99d9cd590a3e9b5a90ea017210ed9556 # v4.0.0 (https://github.com/1Password/load-secrets-action/releases/tag/v4.0.0) + with: + export-env: false + env: + OP_SERVICE_ACCOUNT_TOKEN: '${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}' + GPG_PASSPHRASE: op://kv_app_infra/arabot-1_SIGN_CERTS/credential + GPG_PRIVATE_KEY: op://kv_app_infra/arabot-1_SIGN_CERTS/private_key + ARABOT_PAT: op://kv_app_infra/ARABOT_PAT/credential + + - name: Checkout actions + uses: actions/checkout@v7.0.0 + with: + fetch-depth: 1 + sparse-checkout: | + .github/actions/setup + + - name: Setup + uses: ./.github/actions/setup + with: + token: ${{ steps.load-secrets.outputs.ARABOT_PAT }} + fetch-depth: 0 + ref: ${{ inputs.base_commit || 'main' }} + + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0 (https://github.com/crazy-max/ghaction-import-gpg/releases/tag/v7.0.0) + with: + gpg_private_key: ${{ steps.load-secrets.outputs.GPG_PRIVATE_KEY }} + passphrase: ${{ steps.load-secrets.outputs.GPG_PASSPHRASE }} + git_user_signingkey: true + git_commit_gpgsign: true + + # This flow owns the "assistant" release scope (see .github/release-scopes.yml): the + # service plus its version-only packages. App-scoped packages are versioned by the app + # release flow (app-release-start.yml), which symmetrically owns the "app" scope. + - name: Update version and changelog + uses: ./.github/actions/changeset-version + with: + scope: assistant + prettier_changelog: "true" + github_token: ${{ steps.load-secrets.outputs.ARABOT_PAT }} + + # Versioning runs before the release branch exists so a dispatch without pending + # changesets exits here without leaving an empty branch or PR behind. + - name: Detect version changes + id: changes + run: echo "dirty=$([ -n "$(git status --porcelain)" ] && echo true || echo false)" >> $GITHUB_OUTPUT + + - name: Get package version + if: steps.changes.outputs.dirty == 'true' + id: package-version + uses: martinbeentjes/npm-get-version-action@3cf273023a0dda27efcd3164bdfb51908dd46a5b # v1.3.1 (https://github.com/martinbeentjes/npm-get-version-action/releases/tag/v1.3.1) + with: + path: apps/assistant + + # Per-package tag in the changesets-native format: @aragon/assistant@0.2.0. The tag anchors + # on the deployable service; the version-only packages ship inside their consumers untagged. + - name: Compute release tag + if: steps.changes.outputs.dirty == 'true' + id: release-tag + run: echo "name=@aragon/assistant@${{ steps.package-version.outputs.current-version }}" >> $GITHUB_OUTPUT + + - name: Generate version summary + if: steps.changes.outputs.dirty == 'true' + id: version-summary + uses: ./.github/actions/generate-version-summary + with: + scope: assistant + + - name: Generate release branch name + if: steps.changes.outputs.dirty == 'true' + id: branch-name + run: echo "name=release/assistant/$(date +'%Y-%m-%d_%H-%M')" >> $GITHUB_OUTPUT + + - name: Commit and push release branch + if: steps.changes.outputs.dirty == 'true' + run: | + git checkout -b ${{ steps.branch-name.outputs.name }} + git add --all + git commit -m "Release ${{ steps.release-tag.outputs.name }}" + git push origin ${{ steps.branch-name.outputs.name }} + + - name: Ensure Pull Request + if: steps.changes.outputs.dirty == 'true' + uses: ./.github/actions/gh-ensure-pr + with: + base: main + head: ${{ steps.branch-name.outputs.name }} + title: Release ${{ steps.release-tag.outputs.name }} + body: | + Merging this PR releases `${{ steps.release-tag.outputs.name }}`: the merge commit is + tagged and the tag triggers the production deploy to assistant.aragon.org. The + version-only packages below ship inside their consumers and get no tag of their own. + + ${{ steps.version-summary.outputs.summary }} + token: ${{ steps.load-secrets.outputs.ARABOT_PAT }} diff --git a/.github/workflows/assistant-release-version.yml b/.github/workflows/assistant-release-version.yml deleted file mode 100644 index 51c2ea3efd..0000000000 --- a/.github/workflows/assistant-release-version.yml +++ /dev/null @@ -1,103 +0,0 @@ -# The "Assistant Release Version" workflow prepares the "Version Packages: assistant" pull request -# ON DEMAND (trunk-based: merging to main never starts a release by itself). Dispatching it -# recreates the changeset-release/assistant branch from main, applies `changeset version` scoped to -# the assistant domain packages, and opens/updates the PR with the accumulated version bump and -# CHANGELOG. Merging that PR is the release act — the finalize workflow tags it -# (@aragon/assistant@x.y.z) and the tag triggers the production deploy. - -name: Assistant Release Version - -on: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true - -permissions: - contents: write - pull-requests: write - -jobs: - version: - runs-on: ubuntu-latest - steps: - - name: Load secrets - id: load-secrets - uses: 1password/load-secrets-action@3a12b0ab99d9cd590a3e9b5a90ea017210ed9556 # v4.0.0 (https://github.com/1Password/load-secrets-action/releases/tag/v4.0.0) - with: - export-env: false - env: - OP_SERVICE_ACCOUNT_TOKEN: '${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}' - GPG_PASSPHRASE: op://kv_app_infra/arabot-1_SIGN_CERTS/credential - GPG_PRIVATE_KEY: op://kv_app_infra/arabot-1_SIGN_CERTS/private_key - ARABOT_PAT: op://kv_app_infra/ARABOT_PAT/credential - - - name: Checkout actions - uses: actions/checkout@v7.0.0 - with: - fetch-depth: 1 - sparse-checkout: | - .github/actions/setup - - - name: Setup - uses: ./.github/actions/setup - with: - token: ${{ steps.load-secrets.outputs.ARABOT_PAT }} - fetch-depth: 0 - ref: main - - - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0 (https://github.com/crazy-max/ghaction-import-gpg/releases/tag/v7.0.0) - with: - gpg_private_key: ${{ steps.load-secrets.outputs.GPG_PRIVATE_KEY }} - passphrase: ${{ steps.load-secrets.outputs.GPG_PASSPHRASE }} - git_user_signingkey: true - git_commit_gpgsign: true - - - name: Recreate version branch from main - run: git checkout -B changeset-release/assistant main - - # This flow owns the assistant domain: @aragon/assistant plus its version-only packages - # (contracts and the assistant-chat widget, which ships inside the app but is released by - # its domain owner). App-scoped packages are versioned by the app release flow - # (app-release-start.yml), which symmetrically scopes to @aragon/app. - - name: Update version and changelog - uses: ./.github/actions/changeset-version - with: - scope: "@aragon/assistant @aragon/assistant-contracts @aragon/assistant-chat" - prettier_changelog: "true" - github_token: ${{ steps.load-secrets.outputs.ARABOT_PAT }} - - - name: Detect version changes - id: changes - run: echo "dirty=$([ -n "$(git status --porcelain)" ] && echo true || echo false)" >> $GITHUB_OUTPUT - - - name: Get package version - if: steps.changes.outputs.dirty == 'true' - id: package-version - uses: martinbeentjes/npm-get-version-action@3cf273023a0dda27efcd3164bdfb51908dd46a5b # v1.3.1 (https://github.com/martinbeentjes/npm-get-version-action/releases/tag/v1.3.1) - with: - path: apps/assistant - - - name: Commit and push version branch - if: steps.changes.outputs.dirty == 'true' - run: | - git add --all - git commit -m "Version @aragon/assistant@${{ steps.package-version.outputs.current-version }}" - git push --force origin changeset-release/assistant - - - name: Ensure Pull Request - if: steps.changes.outputs.dirty == 'true' - uses: ./.github/actions/gh-ensure-pr - with: - base: main - head: changeset-release/assistant - title: "Version Packages: assistant (@aragon/assistant@${{ steps.package-version.outputs.current-version }})" - body: | - Accumulated version bump and changelog for the assistant service, maintained automatically - from pending changesets on `main`. - - **Merging this PR releases `@aragon/assistant@${{ steps.package-version.outputs.current-version }}`**: - the merge is tagged and the tag triggers the production deploy to assistant.aragon.org. - token: ${{ steps.load-secrets.outputs.ARABOT_PAT }} diff --git a/.github/workflows/scripts/generateReleaseSummary.js b/.github/workflows/scripts/generateReleaseSummary.js index 2dd4b61287..cae4c21573 100644 --- a/.github/workflows/scripts/generateReleaseSummary.js +++ b/.github/workflows/scripts/generateReleaseSummary.js @@ -104,10 +104,14 @@ const commitMatchesPathFilter = (commit, patterns, git = runGit) => { ); }; +// Any workspace's release commit is dropped, not just this package's: other flows' release +// commits (e.g. "Release @aragon/assistant@0.2.0") touch paths shared with this filter's scope +// and would otherwise leak into the summary as "Other Changes". +const RELEASE_COMMIT_RE = /^Release @aragon\/[^@\s]+@\d+\.\d+\.\d+/; + const collectScopedCommits = ({ baseRef, headRef = 'HEAD', - packageName, patterns, git = runGit, }) => { @@ -118,7 +122,6 @@ const collectScopedCommits = ({ range, '--pretty=format:%H%x00%s', ]); - const releasePrefix = `Release ${packageName}@`; return log .split('\n') @@ -129,7 +132,7 @@ const collectScopedCommits = ({ }) .filter( ({ commit, subject }) => - !subject.startsWith(releasePrefix) && + !RELEASE_COMMIT_RE.test(subject) && commitMatchesPathFilter(commit, patterns, git), ); }; @@ -213,7 +216,6 @@ const generateSummary = async ({ core }) => { const patterns = readPathFilter(filterPath, pathFilter); const commits = collectScopedCommits({ baseRef, - packageName, patterns, }); console.log( diff --git a/.github/workflows/scripts/generateReleaseSummary.test.js b/.github/workflows/scripts/generateReleaseSummary.test.js index 7a29737636..056093ec95 100644 --- a/.github/workflows/scripts/generateReleaseSummary.test.js +++ b/.github/workflows/scripts/generateReleaseSummary.test.js @@ -101,6 +101,14 @@ test('uses the package tag integration commit and filters first-parent history', 'feature', 'feat(APP-123): add app feature (#42)', ); + // Another workspace's release commit touches paths inside this filter's scope + // (the app bundles assistant packages) and must not leak into the summary. + commitFile( + repository, + 'packages/assistant-chat/package.json', + '{"version":"0.2.0"}', + 'Release @aragon/assistant@0.2.0', + ); git(repository, ['checkout', '-b', 'feature/multi-commit']); commitFile( @@ -139,8 +147,11 @@ test('uses the package tag integration commit and filters first-parent history', ); const commits = collectScopedCommits({ baseRef, - packageName: '@aragon/app', - patterns: ['apps/app/**', 'pnpm-lock.yaml'], + patterns: [ + 'apps/app/**', + 'packages/assistant-chat/**', + 'pnpm-lock.yaml', + ], git: repositoryGit, }); diff --git a/.github/workflows/scripts/generateVersionSummary.js b/.github/workflows/scripts/generateVersionSummary.js new file mode 100644 index 0000000000..d962d9b3a3 --- /dev/null +++ b/.github/workflows/scripts/generateVersionSummary.js @@ -0,0 +1,77 @@ +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { extractChangelogSection } = require('./readChangelog'); +const { resolveReleaseScope } = require('./releaseScopes'); + +// Builds a release-PR summary from the CHANGELOG entries written by `changeset version`: one +// "## @" section per bumped package. Works for any release scope — runs after +// `changeset version` and before the release commit, so a bumped package is simply one whose +// package.json has an uncommitted change. (Its sibling, generateReleaseSummary.js, builds the +// commit-history summary used by flows that describe a release in terms of merged PRs instead.) + +const run = (command, args, cwd) => + execFileSync(command, args, { cwd, maxBuffer: 64 * 1024 * 1024 }) + .toString() + .trim(); + +const generateSummary = ({ core, scope, cwd = process.cwd() }) => { + // 1. Map workspace package names to their directories. + const workspaces = JSON.parse( + run('pnpm', ['m', 'ls', '--json', '--depth', '-1'], cwd), + ); + + // 2. Keep the scoped packages bumped by `changeset version` (uncommitted package.json change). + const bumpedPackages = scope + .map((name) => workspaces.find((workspace) => workspace.name === name)) + .filter((workspace) => workspace != null) + .filter((workspace) => { + const manifest = path.join(workspace.path, 'package.json'); + return run('git', ['status', '--porcelain', '--', manifest], cwd); + }); + + // 3. Render each bumped package as its new version plus the matching CHANGELOG section. + const sections = bumpedPackages.map((workspace) => { + const { name, version } = JSON.parse( + fs.readFileSync(path.join(workspace.path, 'package.json'), 'utf8'), + ); + const changelog = fs.readFileSync( + path.join(workspace.path, 'CHANGELOG.md'), + 'utf8', + ); + const changes = + extractChangelogSection(changelog, version) ?? 'No changes.'; + + return `## ${name}@${version}\n\n${changes}`; + }); + + core.setOutput('summary', sections.join('\n\n') || 'No packages bumped.'); +}; + +module.exports = { generateSummary }; + +// Standalone runner: SCOPE names a release scope in .github/release-scopes.yml. +if (require.main === module) { + if (!process.env.SCOPE) { + console.error('SCOPE is required.'); + process.exit(1); + } + const scope = resolveReleaseScope(process.env.SCOPE); + + const core = { + setOutput: (name, value) => { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `${name}< { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'version-summary-')), + ); + execFileSync('git', ['init', '--initial-branch=main'], { + cwd: directory, + stdio: 'ignore', + }); + writeFile( + directory, + 'package.json', + '{"name":"fixture-root","private":true}', + ); + writeFile( + directory, + 'pnpm-workspace.yaml', + 'packages:\n - "apps/*"\n - "packages/*"\n', + ); + + return directory; +}; + +const git = (repository, args) => + execFileSync( + 'git', + [ + '-c', + 'user.name=Version Summary Test', + '-c', + 'user.email=version-summary@example.com', + ...args, + ], + { cwd: repository, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + +const writeFile = (repository, file, content) => { + const target = path.join(repository, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +}; + +const writePackage = (repository, workspacePath, name, version) => + writeFile( + repository, + path.join(workspacePath, 'package.json'), + JSON.stringify({ name, version }), + ); + +test('summarizes the scoped packages bumped by changeset version', () => { + const repository = createWorkspaceRepository(); + + try { + writePackage( + repository, + 'apps/assistant', + '@aragon/assistant', + '0.1.0', + ); + writePackage( + repository, + 'packages/assistant-chat', + '@aragon/assistant-chat', + '0.0.1', + ); + writePackage(repository, 'apps/app', '@aragon/app', '1.0.0'); + git(repository, ['add', '--all']); + git(repository, ['commit', '-m', 'chore: scaffold workspaces']); + + // Simulate `changeset version`: assistant and chat bump; app bumps too but is + // outside the scope; contracts is in scope but has no pending changesets. + writePackage( + repository, + 'apps/assistant', + '@aragon/assistant', + '0.2.0', + ); + writeFile( + repository, + 'apps/assistant/CHANGELOG.md', + '# @aragon/assistant\n\n## 0.2.0\n\n### Minor Changes\n\n- abc123: add support intake\n\n## 0.1.0\n\n### Patch Changes\n\n- old entry\n', + ); + writePackage( + repository, + 'packages/assistant-chat', + '@aragon/assistant-chat', + '0.1.0', + ); + writeFile( + repository, + 'packages/assistant-chat/CHANGELOG.md', + '# @aragon/assistant-chat\n\n## 0.1.0\n\n### Minor Changes\n\n- def456: add chat widget\n', + ); + writePackage(repository, 'apps/app', '@aragon/app', '1.1.0'); + + const outputs = {}; + generateSummary({ + core: { setOutput: (name, value) => (outputs[name] = value) }, + scope: [ + '@aragon/assistant', + '@aragon/assistant-contracts', + '@aragon/assistant-chat', + ], + cwd: repository, + }); + + assert.equal( + outputs.summary, + '## @aragon/assistant@0.2.0\n\n### Minor Changes\n\n- abc123: add support intake\n\n' + + '## @aragon/assistant-chat@0.1.0\n\n### Minor Changes\n\n- def456: add chat widget', + ); + } finally { + fs.rmSync(repository, { recursive: true, force: true }); + } +}); + +test('reports when no scoped package was bumped', () => { + const repository = createWorkspaceRepository(); + + try { + writePackage( + repository, + 'apps/assistant', + '@aragon/assistant', + '0.1.0', + ); + git(repository, ['add', '--all']); + git(repository, ['commit', '-m', 'chore: scaffold workspaces']); + + const outputs = {}; + generateSummary({ + core: { setOutput: (name, value) => (outputs[name] = value) }, + scope: ['@aragon/assistant'], + cwd: repository, + }); + + assert.equal(outputs.summary, 'No packages bumped.'); + } finally { + fs.rmSync(repository, { recursive: true, force: true }); + } +}); + +test('extracts a single version section from a changesets changelog', () => { + const changelog = + '# @aragon/assistant\n\n## 1.2.0\n\n### Minor Changes\n\n- new entry\n\n## 1.1.9\n\n- previous entry\n'; + + assert.equal( + extractChangelogSection(changelog, '1.2.0'), + '### Minor Changes\n\n- new entry', + ); + assert.equal(extractChangelogSection(changelog, '9.9.9'), null); +}); diff --git a/.github/workflows/scripts/readChangelog.js b/.github/workflows/scripts/readChangelog.js index ccbd1f1843..e1f47f3498 100644 --- a/.github/workflows/scripts/readChangelog.js +++ b/.github/workflows/scripts/readChangelog.js @@ -1,6 +1,20 @@ const fs = require('node:fs'); -module.exports = async ({ core }) => { +// Extracts the changelog section for a version from a changesets-generated CHANGELOG +// (sections start with "## x.y.z"). Returns null when the version has no section. +const extractChangelogSection = (changelog, version) => { + const versionChanges = changelog + .split(/(?=## \d+\.\d+\.\d+)/g) + .find((changes) => changes.startsWith(`## ${version}`)); + + if (!versionChanges) { + return null; + } + + return versionChanges.replace(`## ${version}`, '').trim(); +}; + +const readChangelog = async ({ core }) => { const { version, path } = process.env; // Check if version and path are provided @@ -25,11 +39,9 @@ module.exports = async ({ core }) => { flag: 'r', }); - const versionChanges = changelog - .split(/(?=## \d+\.\d+\.\d+)/g) - .find((changes) => changes.startsWith(`## ${version}`)); + const parsedChanges = extractChangelogSection(changelog, version); - if (!versionChanges) { + if (parsedChanges == null) { core.warning( `No changes found for version ${version} in the changelog.`, ); @@ -37,13 +49,11 @@ module.exports = async ({ core }) => { return; } - const parsedChanges = versionChanges - .replace(`## ${version}`, '') - .trim(); - core.info(`Setting output: ${parsedChanges}.`); core.setOutput('changes', parsedChanges); } catch (error) { core.setFailed(error); } }; + +module.exports = { extractChangelogSection, readChangelog }; diff --git a/.github/workflows/scripts/releaseScopes.js b/.github/workflows/scripts/releaseScopes.js new file mode 100644 index 0000000000..5c2cd8f397 --- /dev/null +++ b/.github/workflows/scripts/releaseScopes.js @@ -0,0 +1,20 @@ +const fs = require('node:fs'); +const { parse } = require('yaml'); + +// Reads the central package→release-scope mapper (.github/release-scopes.yml): each scope is the +// set of packages one release flow versions together. Consumed by the release workflows (to +// resolve their changeset scoping) and by validateChangesets.js. +const readReleaseScopes = (scopesPath = '.github/release-scopes.yml') => + parse(fs.readFileSync(scopesPath, 'utf8')); + +const resolveReleaseScope = (scopeName, scopesPath) => { + const packages = readReleaseScopes(scopesPath)[scopeName]; + + if (!Array.isArray(packages) || packages.length === 0) { + throw new Error(`Unknown release scope: ${scopeName}`); + } + + return packages; +}; + +module.exports = { readReleaseScopes, resolveReleaseScope }; diff --git a/.github/workflows/scripts/validateChangesets.js b/.github/workflows/scripts/validateChangesets.js new file mode 100644 index 0000000000..9141228daf --- /dev/null +++ b/.github/workflows/scripts/validateChangesets.js @@ -0,0 +1,66 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { parse } = require('yaml'); +const { readReleaseScopes } = require('./releaseScopes'); + +// A changeset bumping packages from two different release scopes breaks every scoped release +// flow: `changeset version --ignore` refuses mixed ignored/not-ignored changesets. Validated in +// CI (`pnpm validate:changesets`, part of `pnpm test`) so the mix surfaces on the PR that adds +// it, not when a release later fails to start. + +// A changeset's frontmatter names the bumped packages, e.g. `"@aragon/app": minor`. +const parseChangesetPackages = (content) => { + const frontmatter = content.match(/^---\n([\s\S]*?)\n---/); + return frontmatter ? Object.keys(parse(frontmatter[1]) ?? {}) : []; +}; + +const validateChangesets = ({ changesetDir, scopesPath }) => { + const scopes = readReleaseScopes(scopesPath); + const scopeByPackage = new Map( + Object.entries(scopes).flatMap(([scope, packages]) => + packages.map((name) => [name, scope]), + ), + ); + + const changesets = fs + .readdirSync(changesetDir) + .filter((file) => file.endsWith('.md') && file !== 'README.md'); + + return changesets.flatMap((file) => { + const packages = parseChangesetPackages( + fs.readFileSync(path.join(changesetDir, file), 'utf8'), + ); + + const unknown = packages.filter((name) => !scopeByPackage.has(name)); + if (unknown.length > 0) { + return `${file}: ${unknown.join(', ')} missing from ${scopesPath} — every released package must belong to a release scope.`; + } + + const usedScopes = [ + ...new Set(packages.map((name) => scopeByPackage.get(name))), + ]; + if (usedScopes.length > 1) { + return `${file}: mixes release scopes (${usedScopes.join(', ')}) — split it into one changeset per scope, changesets refuses mixed changesets under --ignore.`; + } + + return []; + }); +}; + +module.exports = { parseChangesetPackages, validateChangesets }; + +// Standalone runner +if (require.main === module) { + const errors = validateChangesets({ + changesetDir: '.changeset', + scopesPath: '.github/release-scopes.yml', + }); + + if (errors.length > 0) { + for (const error of errors) { + console.error(error); + } + process.exit(1); + } + console.log('Changesets are valid: no cross-scope mixes.'); +} diff --git a/.github/workflows/scripts/validateChangesets.test.js b/.github/workflows/scripts/validateChangesets.test.js new file mode 100644 index 0000000000..ae050e98f9 --- /dev/null +++ b/.github/workflows/scripts/validateChangesets.test.js @@ -0,0 +1,73 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { validateChangesets } = require('./validateChangesets'); + +const SCOPES_YAML = + 'app:\n - "@aragon/app"\nassistant:\n - "@aragon/assistant"\n - "@aragon/assistant-chat"\n'; + +const createFixture = (changesets) => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'validate-changesets-'), + ); + const changesetDir = path.join(directory, '.changeset'); + fs.mkdirSync(changesetDir); + + const scopesPath = path.join(directory, 'release-scopes.yml'); + fs.writeFileSync(scopesPath, SCOPES_YAML); + fs.writeFileSync(path.join(changesetDir, 'README.md'), '# changesets'); + for (const [file, packages] of Object.entries(changesets)) { + const frontmatter = packages + .map((name) => `"${name}": patch`) + .join('\n'); + fs.writeFileSync( + path.join(changesetDir, file), + `---\n${frontmatter}\n---\n\nSome change\n`, + ); + } + + return { directory, changesetDir, scopesPath }; +}; + +test('accepts changesets that stay within one release scope', () => { + const { directory, changesetDir, scopesPath } = createFixture({ + 'app-change.md': ['@aragon/app'], + 'assistant-change.md': ['@aragon/assistant', '@aragon/assistant-chat'], + }); + + try { + assert.deepEqual(validateChangesets({ changesetDir, scopesPath }), []); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('rejects a changeset mixing packages from different release scopes', () => { + const { directory, changesetDir, scopesPath } = createFixture({ + 'mixed-change.md': ['@aragon/app', '@aragon/assistant-chat'], + }); + + try { + const errors = validateChangesets({ changesetDir, scopesPath }); + assert.equal(errors.length, 1); + assert.match(errors[0], /mixed-change\.md: mixes release scopes/); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('rejects a changeset naming a package outside every release scope', () => { + const { directory, changesetDir, scopesPath } = createFixture({ + 'unknown-change.md': ['@aragon/new-package'], + }); + + try { + const errors = validateChangesets({ changesetDir, scopesPath }); + assert.equal(errors.length, 1); + assert.match(errors[0], /@aragon\/new-package missing from/); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/AGENTS.md b/AGENTS.md index 3e89e1bfcb..16a254a1a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,30 +6,25 @@ This file is the team-shared agent entry point. `CLAUDE.md` imports it via `@AGE ## Monorepo layout -- `apps/app/` — the Aragon App (app.aragon.org): all app source, configs (`next.config.mjs`, `tsconfig.json`, `jest.config.js`), `e2e/`, `docs/`, `scripts/`, `CHANGELOG.md`. Package name stays `@aragon/app`. -- `apps/assistant/` — the assistant service (assistant.aragon.org): Hono API behind the in-app support chat. Own Vercel project, continuous dev deploys from `main`, production released through its Version-PR flow (see `apps/assistant/README.md`). -- `packages/assistant-contracts/` — shared zod contracts between the assistant service and the assistant-chat widget. Domain-scoped by design, not a catch-all contracts package. Built with `tsup` to `dist/` (CJS + ESM + `.d.ts`); Turbo `^build` and the shared-deploy workflow compile it before dependents type-check/test/deploy. -- `apps/*`, `packages/*` — reserved for future workspaces. -- Root — workspace infra only: `pnpm-workspace.yaml`, `turbo.json`, `biome.json`, `.github/`, `.husky/`, `.changeset/`, agent infra (`.agents/`, `.claude/`). Root `package.json` has no version; each workspace is versioned independently via changesets with per-package tags (`@aragon/app@1.17.0`) and release branches (`release/app/…`). See `apps/app/docs/projectDocs/release-process.md`. -- CI: workflows in `.github/workflows/` are grouped per app (`app-*.yml` for `apps/app`, `assistant-*.yml` for `apps/assistant`, `shared-*.yml` reusable). Root scripts proxy through `turbo run `, so `pnpm type-check` etc. work from the repo root. +- `apps/*` — deployable applications, one Vercel project each. Every app owns its source, configs, docs and CHANGELOG; workspace specifics live in the workspace's README, not here. +- `packages/*` — version-only workspace libraries that ship inside their consumers. Libraries that ship `dist/` override Turbo `build` in their own `turbo.json` (`outputs: ["dist/**"]`, `cache: true`) so `^build` compiles them before dependents run. +- Root — workspace infra only: `pnpm-workspace.yaml`, `turbo.json`, `biome.json`, `.github/`, `.husky/`, `.changeset/`, agent infra (`.agents/`, `.claude/`). The root `package.json` has no version. +- CI: workflows in `.github/workflows/` are named per workspace (`app-*.yml`, `assistant-*.yml`) plus reusable `shared-*.yml`. Root scripts proxy through `turbo run `, so `pnpm type-check` etc. work from the repo root. -### Releases — each flow owns its packages +### Releases -Every deployable package releases through its own flow, and each flow declares the packages it versions via the `scope` input of the `changeset-version` action (the inversion into changesets `--ignore` flags happens once, inside the action): +Every deployable workspace releases independently through the same PR-based flow; the packages each flow versions together are declared once in `.github/release-scopes.yml` (a flow names its scope via the `scope` input of the `changeset-version` action; the inversion into changesets `--ignore` flags happens inside the action). The shape: dispatch `-release-start` → it runs `changeset version` for the scoped packages and opens a `Release @x.y.z` PR (branch `release//…`) describing every bumped package → merging the PR is the release act → `-release-pr-finalize` tags the release (`@aragon/app@1.17.0`) and the tag triggers the production deploy. -- **App** — the release ceremony `app-release-start` → release PR (`release/app/…`) → staging checks → merge → `app-release-pr-finalize`, scoped to `@aragon/app`. Finalize tags the tested SHA (`@aragon/app@1.17.0`) and the tag triggers the production deploy. See `apps/app/docs/projectDocs/release-process.md`. -- **Assistant** — a Version-PR bot (`assistant-release-version.yml`) keeps a "Version Packages: assistant" PR up to date from pending changesets on `main`; merging it is the release act — finalize tags the merge commit (`@aragon/assistant@0.2.0`) and the tag triggers the production deploy. Scope: `@aragon/assistant` + `@aragon/assistant-contracts`. -- `packages/*` are version-only and ship inside their consumers; each belongs to the scope of its domain owner's flow (`assistant-contracts` → assistant flow). - -Versions stay independent per package — no lockstep. A `release-all` orchestrator (dispatch with package selection) is a planned follow-up. +`packages/*` get no tags or flows of their own and belong to the scope whose release deploys them (a package bundled by exactly one app joins that app's scope; a package shared across scopes stays with its domain owner). Versions never move in lockstep. A single changeset must never mix packages from different release scopes (changesets refuses mixed ignored/not-ignored changesets, which breaks every scoped flow) — write one changeset per scope; CI enforces this via `pnpm validate:changesets`. Details, including the app-specific staging ceremony: `apps/app/docs/projectDocs/release-process.md`. ### Adding a new workspace — the mappers Cross-cutting workspace knowledge lives in root-level mappers; register a new workspace there instead of touching individual workflows: - `.github/filters.yml` — workspace→paths mapper for CI change detection (dorny/paths-filter): gates optional side-deploys (the app itself deploys on every PR/push and needs no filter). +- `.github/release-scopes.yml` — package→release-scope mapper: which packages each release flow versions together. Read by the release workflows and by `pnpm validate:changesets`. - `pnpm-workspace.yaml` `catalog:` — central version pins for shared tooling/deps; workspaces reference them as `"catalog:"`, bumps happen once at the root (then run the full test fan-out — a catalog bump touches every workspace and triggers releases everywhere). -- Releases: a new deployable workspace gets its own release flow (or joins an existing domain flow) by declaring a `scope` in its `changeset-version` call — other flows are not touched. +- Releases: a new deployable workspace gets its own release flow (or joins an existing domain flow) by adding its packages to a scope in `release-scopes.yml` and naming that scope in its `changeset-version` call — other flows are not touched. Shared build/test config also extends from the root: `tsconfig.base.json` (workspace tsconfigs `extends` it) and `jest.config.base.js` (node workspaces use `createNodeConfig`, jsdom workspaces spread `baseConfig` + `createTsJestTransform`). Lint/format is already root-only (`biome.json`). diff --git a/apps/app/docs/projectDocs/release-process.md b/apps/app/docs/projectDocs/release-process.md index 63be968f10..a2e00cfbab 100644 --- a/apps/app/docs/projectDocs/release-process.md +++ b/apps/app/docs/projectDocs/release-process.md @@ -2,7 +2,7 @@ This document describes the release process for Aragon App. -This process is app-scoped: the release flow versions only `@aragon/app` (via the `scope` input of the `changeset-version` action). Other workspaces release through their own flows — e.g. the assistant service via its Version-PR bot. See the "Releases" section of the root `AGENTS.md` for the per-package model. +This process is app-scoped: the release flow versions only the packages of the `app` release scope (declared in `.github/release-scopes.yml` and referenced via the `scope` input of the `changeset-version` action). Other workspaces release through their own flows — e.g. the assistant service via its own release-PR flow (`assistant-release-start` → merge → `assistant-release-pr-finalize`). See the "Releases" section of the root `AGENTS.md` for the per-package model. ## Versioning & tags (monorepo) diff --git a/apps/app/e2e/tests/smoke/supportChat/supportChat.spec.ts b/apps/app/e2e/tests/smoke/supportChat/supportChat.spec.ts index ee45105549..00d3216d39 100644 --- a/apps/app/e2e/tests/smoke/supportChat/supportChat.spec.ts +++ b/apps/app/e2e/tests/smoke/supportChat/supportChat.spec.ts @@ -5,9 +5,11 @@ const featureFlagCookieName = 'aragon.featureFlags.overrides'; const supportPortalUrl = 'https://aragonassociation.atlassian.net/servicedesk/customer/portal/3'; +const supportEmailHref = 'mailto:support@aragon.org'; + // The assistant runs on its own origin (NEXT_PUBLIC_ASSISTANT_URL); with the feature flag on, -// the help click opens the chat directly. The chat streams from POST /chat; the ticket goes -// through POST /issues/preview. +// the navigation-bar trigger opens the chat side panel. The chat streams from POST /chat; the +// ticket goes through POST /issues/preview. const chatRoutePattern = '**/chat'; const previewRoutePattern = '**/issues/preview'; @@ -45,9 +47,13 @@ const buildChatStream = (): string => { return `${events.join('')}data: [DONE]\n\n`; }; -const openSupportChat = async (page: Page) => { - await page.getByRole('link', { name: 'Support', exact: true }).click(); -}; +const getChatTrigger = (page: Page) => + page.getByRole('button', { name: 'Open support chat' }); + +// The chat lives in a non-modal side panel (`aside`), not a dialog: the page stays interactive +// while it is open. +const getChatPanel = (page: Page) => + page.getByRole('complementary', { name: 'Support chat' }); test.describe('Support chat', () => { test('opens the chat, streams a reply and previews the ticket for sending', async ({ @@ -77,19 +83,20 @@ test.describe('Support chat', () => { ); await page.goto('/'); - await openSupportChat(page); + await getChatTrigger(page).click(); - // Drawer open: accessible title + greeting message + the persistent portal escape hatch. - const drawer = page.getByRole('dialog', { - name: 'Aragon Support Assistant', - }); - await expect(drawer).toBeVisible(); + // Panel open: accessible title + greeting message + the persistent email escape hatch. + const panel = getChatPanel(page); + await expect(panel).toBeVisible(); + await expect( + panel.getByRole('heading', { name: 'Aragon Support Assistant' }), + ).toBeVisible(); await expect( page.getByText("Hi! Tell us what's going on"), ).toBeVisible(); await expect( - drawer.getByRole('link', { name: 'Support portal' }), - ).toHaveAttribute('href', supportPortalUrl); + panel.getByRole('link', { name: 'support@aragon.org' }), + ).toHaveAttribute('href', supportEmailHref); // Send a message and receive the mocked streamed reply. const composer = page.getByRole('textbox', { name: 'Message' }); @@ -106,13 +113,17 @@ test.describe('Support chat', () => { // The explicit preview: the strip button distills the conversation into the reviewable // ticket, whose title the server returned. await page.getByRole('button', { name: 'Prepare ticket' }).click(); - await expect(page.getByText('Proposal page crashes')).toBeVisible(); + // `exact` keeps the non-exact (case-insensitive) match from also hitting the user + // message "The proposal page crashes on load." above the ticket preview. + await expect( + page.getByText('Proposal page crashes', { exact: true }), + ).toBeVisible(); await expect( page.getByRole('button', { name: 'Send ticket' }), ).toBeVisible(); }); - test('keeps the external support link when the flag is disabled', async ({ + test('hides the chat entry points and keeps the external support link when the flag is disabled', async ({ baseURL, context, page, @@ -121,12 +132,17 @@ test.describe('Support chat', () => { await page.goto('/'); - // `exact` keeps DAO cards whose text mentions "support" out of the match. + // `exact` keeps DAO cards whose text mentions "support" out of the match. The footer + // help entry links to the external portal regardless of the flag. const helpLink = page.getByRole('link', { name: 'Support', exact: true, }); await expect(helpLink).toBeVisible(); await expect(helpLink).toHaveAttribute('href', supportPortalUrl); + + // Flag off: neither the navigation-bar trigger nor the chat panel are rendered. + await expect(getChatTrigger(page)).toHaveCount(0); + await expect(getChatPanel(page)).toHaveCount(0); }); }); diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 60aa554adf..ce7620adc4 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -419,6 +419,15 @@ "description": "We couldn't find the page you're looking for.", "title": "Page not found" }, + "supportChat": { + "panel": { + "label": "Support chat" + }, + "trigger": { + "close": "Close support chat", + "open": "Open support chat" + } + }, "userDialog": { "a11y": { "description": "Information about the connected user", diff --git a/apps/app/src/modules/application/components/footer/footer.test.tsx b/apps/app/src/modules/application/components/footer/footer.test.tsx index 9d2888e999..bb647f8354 100644 --- a/apps/app/src/modules/application/components/footer/footer.test.tsx +++ b/apps/app/src/modules/application/components/footer/footer.test.tsx @@ -1,5 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import * as featureFlagsProvider from '@/shared/components/featureFlagsProvider'; +import { render, screen } from '@testing-library/react'; import * as useApplicationVersion from '@/shared/hooks/useApplicationVersion'; import { Footer, type IFooterProps } from './footer'; import { footerLinks } from './footerLinks'; @@ -8,28 +7,12 @@ jest.mock('../../../../shared/components/aragonLogo', () => ({ AragonLogo: () =>
, })); -jest.mock('../supportChat', () => ({ - SupportChat: (props: { isOpen: boolean }) => - props.isOpen ?
: null, -})); - describe('
component', () => { const useApplicationVersionSpy = jest.spyOn( useApplicationVersion, 'useApplicationVersion', ); - const useFeatureFlagsSpy = jest.spyOn( - featureFlagsProvider, - 'useFeatureFlags', - ); - - const setSupportChatEnabled = (enabled: boolean) => { - useFeatureFlagsSpy.mockReturnValue({ - isEnabled: (key) => key === 'supportChat' && enabled, - } as ReturnType); - }; - const createTestComponent = (props?: Partial) => { const completeProps: IFooterProps = { ...props }; @@ -38,12 +21,10 @@ describe('
component', () => { beforeEach(() => { jest.useFakeTimers(); - setSupportChatEnabled(false); }); afterEach(() => { jest.useRealTimers(); - useFeatureFlagsSpy.mockReset(); }); it('renders the aragon logo', () => { @@ -71,33 +52,6 @@ describe('
component', () => { }); }); - it('opens the chat on help click when the support chat flag is enabled', () => { - setSupportChatEnabled(true); - render(createTestComponent()); - - // With the flag on, help becomes a button (not an anchor) so hover / middle-click don't - // advertise a portal URL the click won't follow — the remaining links stay anchors. - expect(screen.getAllByRole('link')).toHaveLength( - footerLinks.length - 1, - ); - const helpButton = screen.getByRole('button', { - name: /footer.link.help/, - }); - - fireEvent.click(helpButton); - expect(screen.getByTestId('support-chat-mock')).toBeInTheDocument(); - }); - - it('keeps the plain portal navigation when the flag is disabled', () => { - setSupportChatEnabled(false); - render(createTestComponent()); - - fireEvent.click(screen.getByRole('link', { name: /footer.link.help/ })); - expect( - screen.queryByTestId('support-chat-mock'), - ).not.toBeInTheDocument(); - }); - it('renders the copyright info', () => { jest.setSystemTime(new Date(2021, 2, 1)); render(createTestComponent()); diff --git a/apps/app/src/modules/application/components/footer/footer.tsx b/apps/app/src/modules/application/components/footer/footer.tsx index 688179cbb2..62f3425167 100644 --- a/apps/app/src/modules/application/components/footer/footer.tsx +++ b/apps/app/src/modules/application/components/footer/footer.tsx @@ -2,14 +2,12 @@ import { Tag } from '@aragon/gov-ui-kit'; import classNames from 'classnames'; -import { type ComponentProps, useState } from 'react'; +import type { ComponentProps } from 'react'; import { AragonLogo } from '@/shared/components/aragonLogo'; import { Container } from '@/shared/components/container'; -import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; import { Link } from '@/shared/components/link'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useApplicationVersion } from '@/shared/hooks/useApplicationVersion'; -import { SupportChat } from '../supportChat'; import { footerLinks } from './footerLinks'; export interface IFooterProps extends ComponentProps<'footer'> {} @@ -21,16 +19,6 @@ export const Footer: React.FC = (props) => { const { className, ...otherProps } = props; const { t } = useTranslations(); - const { isEnabled } = useFeatureFlags(); - - const isSupportChatEnabled = isEnabled('supportChat'); - const [isSupportChatOpen, setIsSupportChatOpen] = useState(false); - - // With the support chat flag on, the help entry is a real button that opens the chat drawer — - // not an anchor, so hover / middle-click don't advertise a portal URL the click won't follow. - // The portal stays reachable inside the widget (header link + error escape hatches). Flag off - // keeps the plain portal anchor and its no-JS / middle-click fallback. - const handleHelpClick = () => setIsSupportChatOpen(true); const year = new Date().getFullYear(); @@ -70,45 +58,21 @@ export const Footer: React.FC = (props) => { />
- {footerLinks.map(({ link, label, target }) => { - if (label === 'help' && isSupportChatEnabled) { - return ( - - ); - } - - return ( - - {t(`app.application.footer.link.${label}`)} - - ); - })} + {footerLinks.map(({ link, label, target }) => ( + + {t(`app.application.footer.link.${label}`)} + + ))}

{t('app.application.footer.copyright', { year })}

- {isSupportChatEnabled && ( - setIsSupportChatOpen(false)} - /> - )} ); }; diff --git a/apps/app/src/modules/application/components/footer/footerLinks.ts b/apps/app/src/modules/application/components/footer/footerLinks.ts index 209261a8c6..7eed85c63c 100644 --- a/apps/app/src/modules/application/components/footer/footerLinks.ts +++ b/apps/app/src/modules/application/components/footer/footerLinks.ts @@ -1,7 +1,6 @@ import type { Translations } from '@/shared/utils/translationsUtils'; -// External support portal, used as the footer help link when the support chat feature is -// disabled and as the fallback destination when the assistant service is unreachable. +// External support portal, linked from the footer help entry. export const SUPPORT_PORTAL_URL = 'https://aragonassociation.atlassian.net/servicedesk/customer/portal/3'; diff --git a/apps/app/src/modules/application/components/layouts/layoutRoot/layoutRoot.tsx b/apps/app/src/modules/application/components/layouts/layoutRoot/layoutRoot.tsx index 61a28ab605..97dc0ce445 100644 --- a/apps/app/src/modules/application/components/layouts/layoutRoot/layoutRoot.tsx +++ b/apps/app/src/modules/application/components/layouts/layoutRoot/layoutRoot.tsx @@ -17,6 +17,10 @@ import { DebugPanelLazy } from '../../debugPanel/lazyDebugPanel'; import { ErrorBoundary } from '../../errorBoundary'; import { Footer } from '../../footer'; import { Providers } from '../../providers'; +import { + SupportChatContextProvider, + SupportChatPanel, +} from '../../supportChat'; import './layoutRoot.css'; export interface ILayoutRootProps { @@ -73,11 +77,22 @@ export const LayoutRoot: React.FC = async (props) => { translations={translationAssets} wagmiInitialState={wagmiInitialState} > - -
{children}
- {isDebugPanelEnabled && } -
-