diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2fde6a4a2..86cbe50c2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,11 +4,12 @@ ## Pre-merge checklist -> ℹ️ These repos are private on the GitHub **Free** plan, so branch protection is -> **not enforced** — CI cannot hard-block a merge. Honoring this checklist is what keeps -> broken changes out of UAT. See [`CONTRIBUTING.md`](../CONTRIBUTING.md). +> ℹ️ A red check only blocks the merge where the branch ruleset lists it as required. +> Honoring this checklist is what keeps broken changes out of UAT. See +> [`CONTRIBUTING.md`](../CONTRIBUTING.md). - [ ] **CI is green** — the `tests` check on this PR passes (never merge on ❌) -- [ ] Branch is **up to date with `main`** (so it is tested against the latest code) +- [ ] Branch is **up to date with its base** (`develop`, or `version-N-hotfix` for a backport) - [ ] New/changed behavior has tests (the coverage gate still passes) - [ ] I self-reviewed the diff +- [ ] If the base is `version-N`: this is the release PR from `version-N-hotfix`, `__version__` is bumped, and `release-source` is green diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 999ce39b0..3072c2ab4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,15 +32,23 @@ name: CI on: workflow_dispatch: + # Default PR types only, deliberately without `edited` (the event a base retarget sends). + # Listening to it and skipping the jobs on title edits was tried and is a trap: a skipped + # job still posts a "skipped" check run, GitHub reads the LATEST check per name, and a + # skipped check satisfies a required check, so a title edit after a red run would mask + # the failure. release-guard.yml instead fails a PR whose base changed until it is pushed + # to or reopened, which is what makes this workflow run against the new base. pull_request: push: - branches: [version-15] + # version-15 is the stable line customers install from; version-15-hotfix is where its + # backports collect until a release merges them into it. See CONTRIBUTING.md. + branches: [version-15, version-15-hotfix] concurrency: group: ci-${{ github.ref }} - # Never auto-cancel a version-15 run (cancelled reads as neutral, not failed → hides - # regressions on the release branch). - cancel-in-progress: ${{ github.ref != 'refs/heads/version-15' }} + # Only auto-cancel PR runs. A cancelled run on a long-lived branch reads as neutral, not + # failed, which hides regressions on the release line. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -64,8 +72,8 @@ jobs: - uses: pre-commit/action@v3.0.1 env: # no-commit-to-branch is a LOCAL guard: it inspects the checked-out - # branch name. On `push: branches: [version-15]` the checkout IS - # version-15, so leaving it on would fail this job on every merge. It + # branch name. On a `push` event the checkout IS the guarded branch, + # so leaving it on would fail this job on every merge. It # has no meaning server-side anyway - the PR requirement is what # enforces it. SKIP: no-commit-to-branch @@ -124,10 +132,25 @@ jobs: - name: node:test (Desk widget + PWA lib) env: NODE_OPTIONS: --unhandled-rejections=strict - # These two suites are plain node:test with only sibling-module imports (no npm deps), - # so they run from the repo root with bare node - no `npm ci` needed. + # These suites are plain node:test with only sibling-module imports (no npm deps), + # so they run from the repo root with bare node - no `npm ci` needed. They were run by + # NOTHING in CI before, which is exactly how a Desk-widget confirmation-card ordering + # bug (a dropped field that renumbered a typed "confirm N" onto the wrong ERP write) + # shipped under a green build. Gate them here. + # + # Each glob is `dir/*.test.mjs` - NOT recursive - so it covers only *.test.mjs files + # sitting directly in that one directory, never a subdirectory's. jarvis/public/js/ + # has two: jarvis_onboarding_banner.test.mjs directly in it (the same gap as the + # widget's, for the Desk nudge bundle - it `require()`s the bundle's CommonJS export + # guard rather than a plain sibling import, see that file's own comment) and + # pump_fence.test.mjs under shared/, which needs its own explicit pattern for exactly + # this reason - it went ungated for a while after jarvis/public/js/*.test.mjs was + # added, on the same silent-mismatch shape this whole job exists to close. A NEW + # subdirectory under jarvis/public/js/ with its own *.test.mjs needs the same: add its + # own line/pattern here, do not assume an existing glob already reaches it. run: | node --test jarvis/public/js/jarvis_chat/widget/*.test.mjs + node --test jarvis/public/js/*.test.mjs jarvis/public/js/shared/*.test.mjs node --test pwa/src/lib/*.test.js # Builds BOTH deploy artifacts (the /jarvis SPA and the /jarvis-mobile PWA) on Node diff --git a/.github/workflows/release-guard.yml b/.github/workflows/release-guard.yml new file mode 100644 index 000000000..b609ecc37 --- /dev/null +++ b/.github/workflows/release-guard.yml @@ -0,0 +1,98 @@ +name: Release guard + +# Stable branches (version-N) accept exactly one kind of PR: the release PR from their own +# hotfix branch (version-N-hotfix). Backports target the hotfix branch; a release is the +# merge of hotfix into stable. This job fails any other PR into a stable branch, and on a +# release PR it also fails unless __version__ moved up and matches the line. See +# CONTRIBUTING.md. +# +# Enforcement is a repo-settings matter, not this file's: the job only blocks a merge once +# `release-source` is listed as a required status check on the version-* rulesets. Until +# then it is advisory, a red check a reviewer has to notice. +# +# Two deliberate choices: +# * pull_request_target, not pull_request: the workflow then comes from the BASE branch's +# copy of this file, so a head branch that predates it, or a fork that deletes it, does +# not silently produce "no check". Safe here because nothing is checked out and no +# head code runs; the job only reads event fields and, for a release PR, fetches one +# file through the API. +# * Its own workflow, not a job in ci.yml: `edited` is the event GitHub sends when a +# PR's BASE is changed. Retargeting a green PR from develop to version-16 must re-run +# this check, but re-running the whole test matrix on every title edit would be waste. + +on: + pull_request_target: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + +# Repeated `edited` events (title tweaks, a double retarget) supersede each other. +concurrency: + group: release-guard-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + release-source: + runs-on: ubuntu-latest + timeout-minutes: 2 + env: + BASE: ${{ github.base_ref }} + HEAD: ${{ github.head_ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + THIS_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + steps: + - name: A retargeted PR must be pushed to or reopened before it can merge + # ci.yml does not run on `edited` (see the note there), so after a base change its + # green checks describe the OLD base. Fail here until a push or a close/reopen + # makes CI run against the new one; both events re-run this job too. + if: github.event.action == 'edited' && github.event.changes.base != null + run: | + echo "::error::Base changed from ${{ github.event.changes.base.ref.from }} to $BASE. CI has not run against the new base: push a commit or close and reopen this PR." + exit 1 + + - name: Only version-N-hotfix (same repo) may open a PR into version-N + run: | + case "$BASE" in + version-[0-9]*-hotfix) echo "hotfix base: any head is allowed"; exit 0 ;; + version-[0-9]*) ;; + *) echo "not a stable base: nothing to check"; exit 0 ;; + esac + if [ "$HEAD_REPO" != "$THIS_REPO" ]; then + echo "::error::PRs into $BASE must come from a branch in $THIS_REPO, not a fork (got $HEAD_REPO:$HEAD)." + exit 1 + fi + if [ "$HEAD" != "${BASE}-hotfix" ]; then + echo "::error::PRs into $BASE must come from ${BASE}-hotfix (got $HEAD). Retarget this PR to ${BASE}-hotfix; a release is cut by merging ${BASE}-hotfix into $BASE." + exit 1 + fi + echo "release PR: $HEAD -> $BASE" + + - name: A release PR must raise __version__ and stay on its line + # Runs only for the release-PR shape (a non-release PR into a stable branch already + # failed above; a non-stable base skips). A release that ships without the bump + # leaves every bench on it stuck behind the control plane's release notice, with + # nothing to dismiss it. Read through the API: nothing is checked out here. + if: startsWith(github.base_ref, 'version-') && !endsWith(github.base_ref, '-hotfix') + run: | + read_version() { + gh api "repos/$THIS_REPO/contents/jarvis/__init__.py?ref=$1" --jq .content \ + | base64 -d | sed -n 's/^__version__ = "\([^"]*\)".*/\1/p' + } + base_v="$(read_version "$BASE_SHA")" + head_v="$(read_version "$HEAD_SHA")" + line="${BASE#version-}" + echo "base $base_v -> head $head_v (line $line)" + if [ -z "$head_v" ]; then + echo "::error::could not read __version__ from jarvis/__init__.py on $HEAD"; exit 1 + fi + if [ "${head_v%%.*}" != "$line" ]; then + echo "::error::__version__ $head_v is not on the $line.x line of $BASE"; exit 1 + fi + if [ "$(printf '%s\n%s\n' "$base_v" "$head_v" | sort -V | tail -1)" != "$head_v" ] || [ "$base_v" = "$head_v" ]; then + echo "::error::__version__ must move up on a release PR: $BASE has $base_v, $HEAD has $head_v. Bump jarvis/__init__.py on $HEAD first."; exit 1 + fi + echo "version bump ok" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..3e25588af --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +# Tags and publishes a release whenever a stable branch (version-N) moves. Under the hotfix +# model the only thing that lands on a stable branch is the release PR from version-N-hotfix, +# and that PR carries the __version__ bump (release-guard.yml refuses it otherwise), so every +# push here IS a release: read __version__, tag the merge commit vN.x.y, publish a GitHub +# Release with notes generated from the previous tag on the same line. +# +# Idempotent: if the tag already exists nothing happens, so a failed run can be re-run from +# the Actions tab (workflow_dispatch) without side effects. "Latest" goes only to the highest +# version-N line, so a v15 release never displaces the v16 one. +# +# The token is github-actions[bot]. The "Release tags" ruleset therefore must NOT carry a +# tag `creation` rule (GitHub refuses the built-in Actions app as a bypass actor); it keeps +# `deletion`, `update` and the vN.N.N name pattern, which is what makes a published tag +# immutable. + +on: + push: + branches: [version-15, version-16] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + tag-and-release: + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Tag and publish if __version__ is new + run: | + set -euo pipefail + v="$(sed -n 's/^__version__ = "\([^"]*\)".*/\1/p' jarvis/__init__.py)" + if [[ ! "$GITHUB_REF_NAME" =~ ^version-[0-9]+$ ]]; then + echo "::error::Release runs only on a version-N branch (got $GITHUB_REF_NAME)"; exit 1 + fi + line="${GITHUB_REF_NAME#version-}" + if [ -z "$v" ] || [ "${v%%.*}" != "$line" ]; then + echo "::error::__version__ '$v' is not on the $line.x line of $GITHUB_REF_NAME"; exit 1 + fi + # Tag and Release are checked separately so a re-run finishes whatever step failed: + # a pushed tag with no Release (API hiccup) must still get its Release. + if git rev-parse -q --verify "refs/tags/v$v" >/dev/null; then + echo "tag v$v exists" + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "v$v" -m "v$v" "$GITHUB_SHA" + git push origin "v$v" + echo "tagged v$v at $GITHUB_SHA" + fi + if gh release view "v$v" >/dev/null 2>&1; then + echo "release v$v exists, nothing to do"; exit 0 + fi + prev="$(git tag -l "v$line.*" --sort=-v:refname | grep -vx "v$v" | head -1 || true)" + highest="$(git ls-remote --heads origin 'version-*' | sed -n 's|.*/version-\([0-9]*\)$|\1|p' | sort -n | tail -1)" + latest=false; [ "$line" = "$highest" ] && latest=true + echo "publishing v$v on $GITHUB_REF_NAME (previous: ${prev:-none}, latest=$latest)" + gh release create "v$v" --verify-tag --title "v$v" --generate-notes \ + ${prev:+--notes-start-tag "$prev"} --latest="$latest" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6b96858f9..6e9cc75d4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,11 +23,12 @@ repos: - id: trailing-whitespace files: "jarvis.*" exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" - # Work lands on develop through a PR, never a direct commit. Branch - # protection is a paid feature on private repos, so this is the local - # equivalent - it holds on every machine that runs `pre-commit install`. + # Work lands on the five long-lived branches (develop, version-N, + # version-N-hotfix) through a PR, never a direct commit. The branch + # rulesets enforce that server-side; this is the local equivalent, and it + # holds on every machine that runs `pre-commit install`. - id: no-commit-to-branch - args: ["--branch", "develop", "--branch", "beta"] + args: ["--branch", "develop", "--pattern", "^version-[0-9]+(-hotfix)?$"] - id: check-merge-conflict - id: check-ast - id: check-json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83d903a6b..6272a98b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,3 +5,31 @@ time. If you find a problem while using Jarvis, please report it through **Support** inside the app. + +## Branches and releases + +| Branch | Role | What may merge into it | +|---|---|---| +| `develop` | default; all work lands here first | feature and fix PRs | +| `version-16-hotfix`, `version-15-hotfix` | backports waiting for the next release | backport PRs (cherry-picks from `develop`), fixes found in production | +| `version-16`, `version-15` | stable; what customers install and what Frappe Cloud tracks | **only** the release PR from the matching hotfix branch | + +- A backport PR targets `version-N-hotfix`, never `version-N`. The `release-source` check + (`.github/workflows/release-guard.yml`) fails any other PR into a stable branch. It + blocks the merge only where the branch ruleset lists it as a required check; wire that + in repo settings, the workflow cannot do it by itself. +- A fix found in production goes to the hotfix branch first, then is forward-ported to + `develop` in its own PR so it is not lost on the next backport. +- A release is one PR, `version-N-hotfix` -> `version-N`, titled `chore: release vN.x.y`, + merged with a merge commit. Before opening it, bump `__version__` in `jarvis/__init__.py` + on the hotfix branch (feature backports bump minor, fix-only bumps patch). On merge the + `Release` workflow (`.github/workflows/release.yml`) tags the merge commit `vN.x.y` and + publishes a GitHub Release with notes generated from the previous tag on that line. + Check the Releases page afterwards; if the run failed, re-run it from the Actions tab. + It resumes whatever step was missing (tag, Release, or nothing). +- Never push directly to any of these five branches; everything lands through a PR. + The `version-N` rulesets enforce this today, and the `version-N-hotfix` rulesets should + match them (PR required, no force-push, no deletion). +- On a release PR the same check also fails unless `__version__` moved up and its major + matches the line, so a release cannot ship without the bump. + diff --git a/frontend/src/api.js b/frontend/src/api.js index 56671c0f0..8f1ec7231 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -39,6 +39,16 @@ export const setAutoApply = (conversation, value) => // null/zeros until then (design doc §6, UsagePane's "Measured usage" block). export const getUsage = (conversation) => call("jarvis.chat.api.get_usage", { conversation: conversation || "" }); +// Context-window meter for one chat: {used, capacity, pct, warn_pct, +// auto_compact_pct, route, compaction_count, last_compacted_at, compacting, +// fresh}. Bench snapshot, refreshed after every completed turn. +export const getConversationContext = (conversation) => + call("jarvis.chat.api.get_conversation_context", { conversation }); +// Summarise older turns. Optional hint = what to keep. Resolves to +// {ok, queued} or {ok:false, reason}; the result arrives later as a +// context:compacted / context:compact_failed event. +export const compactConversation = (conversation, hint = "") => + call("jarvis.chat.api.compact_conversation", { conversation, hint: hint || null }); // Tool runs recorded in one chat, newest turn first, from the PERSISTED tool // rows: the same rows the thread's Activity accordion renders. The Settings // Activity pane used to derive this from the browser's live run stream, which @@ -609,6 +619,17 @@ export const billingPaymentState = () => call("jarvis.account.get_billing_paymen export const checkBillingPayment = (opts) => rawOnboardingCall("jarvis.account.check_billing_payment_status", {}, opts); +// GST invoices + billing details for the billing page (Phase 3b). getInvoices lists the +// customer's own invoices; downloadInvoice returns a base64 PDF (admin re-verifies ownership). +// getBillingProfile/updateBillingDetails drive the editable billing-details card (a partner- +// billed customer gets a read-only "billed through " and cannot edit). +export const getInvoices = () => call("jarvis.account.get_invoices"); +export const downloadInvoice = (erpName) => + call("jarvis.account.download_invoice", { erp_name: erpName }); +export const getBillingProfile = () => call("jarvis.account.get_billing_profile"); +export const updateBillingDetails = (billing) => + call("jarvis.account.update_billing_details", { billing }); + // File input: upload to Frappe's File doctype, return {file_url, file_name}. export async function uploadFile(file) { const fd = new FormData(); diff --git a/frontend/src/components/chat/CompactDialog.spec.js b/frontend/src/components/chat/CompactDialog.spec.js new file mode 100644 index 000000000..4631198d2 --- /dev/null +++ b/frontend/src/components/chat/CompactDialog.spec.js @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { mount } from "@vue/test-utils"; + +vi.mock("frappe-ui", () => ({ + Dialog: { + props: ["modelValue", "options"], + template: "
", + }, + Button: { + props: ["label", "variant", "disabled", "loading"], + emits: ["click"], + template: "", + }, + FormControl: { + props: ["modelValue", "label", "placeholder", "type"], + emits: ["update:modelValue"], + template: + "