Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b12749997 | ||
|
|
e25962fd90 | ||
|
|
39cc3a780a | ||
|
|
06ed5ed8e1 | ||
|
|
18302723e8 |
@@ -0,0 +1,50 @@
|
||||
# Default branch protection
|
||||
|
||||
The intended repository ruleset is stored in
|
||||
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json).
|
||||
It targets the configured default branch and requires pull requests, the strict
|
||||
`Required CI` check from GitHub Actions, resolved review conversations,
|
||||
squash-only merges, and linear history. Branch deletion and force pushes are
|
||||
blocked. Qodana remains advisory and is not part of the required gate.
|
||||
|
||||
The ruleset's `RepositoryRole` actor ID `5` is GitHub's built-in Administrator
|
||||
role. Its `pull_request` bypass mode permits an administrator to bypass rules
|
||||
only while merging an existing pull request; it does not permit a direct push.
|
||||
|
||||
## Repository settings
|
||||
|
||||
Keep squash merge enabled and disable merge commits and rebase merge. Enable
|
||||
auto-merge, the update-branch option, and automatic deletion of merged head
|
||||
branches. Keep the Actions token read-only and do not allow Actions to approve
|
||||
pull-request reviews.
|
||||
|
||||
## Activation and verification
|
||||
|
||||
1. Confirm a pull request and a `master` push each produce exactly one
|
||||
successful `Required CI` check from GitHub Actions integration `15368`.
|
||||
2. For the initial ruleset POST, override the committed JSON's `enforcement`
|
||||
value to `disabled`, then compare GitHub's normalized API response with this
|
||||
file.
|
||||
3. PUT the exact committed JSON to the inspected ruleset to activate it.
|
||||
4. Open a canary pull request and confirm that pending or failing CI, unresolved
|
||||
conversations, and an out-of-date branch block merging; only squash merge is
|
||||
available.
|
||||
5. After merging, confirm the head branch is deleted and the post-merge full
|
||||
E2E, frontend release, and mobile release guards still run.
|
||||
|
||||
If validation exposes a blocker, disable the ruleset rather than deleting it so
|
||||
its configuration and history remain available.
|
||||
|
||||
## Normal publishing flow
|
||||
|
||||
Create a scoped feature branch, open a pull request to `master`, wait for
|
||||
`Required CI`, update the branch if `master` advanced, resolve every review
|
||||
conversation, and squash-merge. For waits expected to exceed 90 seconds, use
|
||||
the workspace `scripts/ci-watch.sh` helper instead of repeatedly polling GitHub.
|
||||
|
||||
## Break glass
|
||||
|
||||
For an incident, an administrator must still open a pull request. Document the
|
||||
incident and why the normal gate cannot complete, then use the PR-only bypass
|
||||
when merging. Monitor all post-merge workflows and open a follow-up pull request
|
||||
for any validation or remediation deferred during the incident.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "Protect default branch",
|
||||
"target": "branch",
|
||||
"enforcement": "active",
|
||||
"bypass_actors": [
|
||||
{
|
||||
"actor_id": 5,
|
||||
"actor_type": "RepositoryRole",
|
||||
"bypass_mode": "pull_request"
|
||||
}
|
||||
],
|
||||
"conditions": {
|
||||
"ref_name": {
|
||||
"include": ["~DEFAULT_BRANCH"],
|
||||
"exclude": []
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"type": "deletion"
|
||||
},
|
||||
{
|
||||
"type": "non_fast_forward"
|
||||
},
|
||||
{
|
||||
"type": "required_linear_history"
|
||||
},
|
||||
{
|
||||
"type": "pull_request",
|
||||
"parameters": {
|
||||
"allowed_merge_methods": ["squash"],
|
||||
"dismiss_stale_reviews_on_push": false,
|
||||
"require_code_owner_review": false,
|
||||
"require_last_push_approval": false,
|
||||
"required_approving_review_count": 0,
|
||||
"required_review_thread_resolution": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "required_status_checks",
|
||||
"parameters": {
|
||||
"do_not_enforce_on_create": false,
|
||||
"required_status_checks": [
|
||||
{
|
||||
"context": "Required CI",
|
||||
"integration_id": 15368
|
||||
}
|
||||
],
|
||||
"strict_required_status_checks_policy": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,32 +1,66 @@
|
||||
name: Qodana Configuration Upload
|
||||
name: Qodana
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [master, beta, canary, internal]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
push:
|
||||
branches: [master, beta, canary, internal]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
timeout-minutes: 10
|
||||
qodana:
|
||||
name: Qodana
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
# v5.0.1
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Qodana Configuration Uploader
|
||||
- name: Require Qodana project token
|
||||
shell: bash
|
||||
env:
|
||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$(pwd):/workspace" \
|
||||
-w /workspace \
|
||||
-e QODANA_CONFIGURATIONS_TOKEN \
|
||||
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
|
||||
--global-configs-file qodana-global-configurations.yaml \
|
||||
--qodana-host https://qodana.cloud
|
||||
set -euo pipefail
|
||||
if [[ -z "${QODANA_TOKEN:-}" ]]; then
|
||||
echo "::error::QODANA_TOKEN is not configured for this repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Qodana
|
||||
# v2026.1.3
|
||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
|
||||
with:
|
||||
use-caches: true
|
||||
cache-default-branch-only: true
|
||||
upload-result: false
|
||||
use-annotations: true
|
||||
pr-mode: ${{ github.event_name == 'pull_request' }}
|
||||
post-pr-comment: true
|
||||
github-token: ${{ github.token }}
|
||||
push-fixes: none
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
|
||||
@@ -48,8 +48,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Repository variables used as CI runner and credit controls:
|
||||
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
|
||||
@@ -511,6 +511,31 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
required-ci:
|
||||
if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'push') }}
|
||||
name: Required CI
|
||||
needs: [format-tests, build-and-unit, e2e-pr]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify required jobs succeeded
|
||||
shell: bash
|
||||
env:
|
||||
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
|
||||
BUILD_AND_UNIT_RESULT: ${{ needs.build-and-unit.result }}
|
||||
E2E_PR_RESULT: ${{ needs.e2e-pr.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for required_job in FORMAT_TESTS_RESULT BUILD_AND_UNIT_RESULT E2E_PR_RESULT; do
|
||||
result="${!required_job:-missing}"
|
||||
if [[ "$result" != "success" ]]; then
|
||||
echo "${required_job}=${result}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
e2e-full:
|
||||
if: >
|
||||
always() &&
|
||||
|
||||
@@ -16,6 +16,16 @@ See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
npm install
|
||||
```
|
||||
|
||||
## Contributing Changes
|
||||
|
||||
Create a scoped feature branch, push it, and open a pull request targeting
|
||||
`master`. Do not push directly to `master`. Merge only after the `Required CI`
|
||||
check succeeds, all review conversations are resolved, and the branch is up to
|
||||
date. Use squash merge so `master` retains linear history.
|
||||
|
||||
See [`.github/BRANCH_PROTECTION.md`](.github/BRANCH_PROTECTION.md) for the
|
||||
repository policy, rollout checks, and emergency bypass procedure.
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
|
||||
+16
-40
@@ -1,46 +1,22 @@
|
||||
#-------------------------------------------------------------------------------#
|
||||
# Qodana analysis is configured by qodana.yaml file #
|
||||
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
|
||||
#-------------------------------------------------------------------------------#
|
||||
|
||||
#################################################################################
|
||||
# WARNING: Do not store sensitive information in this file, #
|
||||
# as its contents will be included in the Qodana report. #
|
||||
#################################################################################
|
||||
version: "1.0"
|
||||
linter: jetbrains/qodana-js:2026.1
|
||||
|
||||
#Specify inspection profile for code analysis
|
||||
profile:
|
||||
name: qodana.starter
|
||||
name: qodana.recommended
|
||||
|
||||
#Enable inspections
|
||||
#include:
|
||||
# - name: <SomeEnabledInspectionId>
|
||||
bootstrap: npm ci --legacy-peer-deps
|
||||
|
||||
#Disable inspections
|
||||
#exclude:
|
||||
# - name: <SomeDisabledInspectionId>
|
||||
# paths:
|
||||
# - <path/where/not/run/inspection>
|
||||
include:
|
||||
- name: Eslint
|
||||
|
||||
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
|
||||
#bootstrap: sh ./prepare-qodana.sh
|
||||
|
||||
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
|
||||
#plugins:
|
||||
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
|
||||
|
||||
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
|
||||
# severityThresholds - configures maximum thresholds for different problem severities
|
||||
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
|
||||
# Code Coverage is available in Ultimate and Ultimate Plus plans
|
||||
#failureConditions:
|
||||
# severityThresholds:
|
||||
# any: 15
|
||||
# critical: 5
|
||||
# testCoverageThresholds:
|
||||
# fresh: 70
|
||||
# total: 50
|
||||
|
||||
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
|
||||
linter: jetbrains/qodana-js:2025.3
|
||||
exclude:
|
||||
- name: All
|
||||
paths:
|
||||
- src/i18n/generated
|
||||
- node_modules.codex-backup
|
||||
- output
|
||||
- .gradle
|
||||
- playwright/.cache
|
||||
- android
|
||||
- ios
|
||||
- app
|
||||
|
||||
@@ -100,6 +100,7 @@ export const ownedFilesByRole = {
|
||||
"session-bootstrap.spec.ts",
|
||||
"superuser-bookings.spec.ts",
|
||||
"superuser-cron.spec.ts",
|
||||
"superuser-customer-rules.spec.ts",
|
||||
"superuser-customer-complaints.spec.ts",
|
||||
"superuser-customers-mass-import.spec.ts",
|
||||
"superuser-department-branding.spec.js",
|
||||
|
||||
@@ -34,20 +34,42 @@ let nextTemporaryCollectionId = -1;
|
||||
|
||||
const canView = computed(canViewCustomerRuleConfiguration);
|
||||
const canManage = computed(canManageCustomerRuleConfiguration);
|
||||
const productRuleDefinitions = computed(() => (
|
||||
const productRuleDefinitions = computed(() =>
|
||||
getCustomerRuleDefinitions().filter((definition) => definition.productImpact)
|
||||
));
|
||||
);
|
||||
const productRuleTranslations = computed(() => ({
|
||||
restrictAdditionalServices: {
|
||||
label: t("customer_rules.attributes.restrictAdditionalServices.label"),
|
||||
description: t("customer_rules.attributes.restrictAdditionalServices.description"),
|
||||
},
|
||||
restrictTankCleaning: {
|
||||
label: t("customer_rules.attributes.restrictTankCleaning.label"),
|
||||
description: t("customer_rules.attributes.restrictTankCleaning.description"),
|
||||
},
|
||||
restrictSpotFree: {
|
||||
label: t("customer_rules.attributes.restrictSpotFree.label"),
|
||||
description: t("customer_rules.attributes.restrictSpotFree.description"),
|
||||
},
|
||||
restrictInteriorCleaning: {
|
||||
label: t("customer_rules.attributes.restrictInteriorCleaning.label"),
|
||||
description: t("customer_rules.attributes.restrictInteriorCleaning.description"),
|
||||
},
|
||||
onlyTankCleaning: {
|
||||
label: t("customer_rules.attributes.onlyTankCleaning.label"),
|
||||
description: t("customer_rules.attributes.onlyTankCleaning.description"),
|
||||
},
|
||||
}));
|
||||
const productRuleTranslation = (attribute, field) => productRuleTranslations.value[attribute]?.[field] ?? attribute;
|
||||
|
||||
const normalizedSnapshot = (rule) => JSON.stringify(serializeCustomerRuleProductRestriction(rule));
|
||||
const isSaving = (attribute) => savingAttributes.value.has(attribute);
|
||||
const hasConflict = (attribute) => conflictAttributes.value.has(attribute);
|
||||
const isDirty = (rule) => savedSnapshots.value.get(rule.attribute) !== normalizedSnapshot(rule);
|
||||
const effectiveDisabledProductIds = (rule) => (
|
||||
[...new Set(rule.collections.flatMap((collection) => collection.product_ids))]
|
||||
);
|
||||
const productIsArchived = (product) => (
|
||||
product?.deleted_at != null || product?.active === false || product?.is_active === false
|
||||
);
|
||||
const effectiveDisabledProductIds = (rule) => [
|
||||
...new Set(rule.collections.flatMap((collection) => collection.product_ids)),
|
||||
];
|
||||
const productIsArchived = (product) =>
|
||||
product?.deleted_at != null || product?.active === false || product?.is_active === false;
|
||||
const productLabel = (product) => {
|
||||
const category = String(product?.category_name ?? product?.category?.name ?? "").trim();
|
||||
return category ? `${product.name} · ${category}` : product.name;
|
||||
@@ -56,21 +78,19 @@ const productLabel = (product) => {
|
||||
const normalizeLoadedRules = (response) => {
|
||||
const normalized = normalizeCustomerRuleProductRestrictionResponse(response);
|
||||
const byAttribute = new Map(normalized.rules.map((rule) => [rule.attribute, rule]));
|
||||
const nextDrafts = productRuleDefinitions.value.map((definition) => (
|
||||
byAttribute.get(definition.attribute)
|
||||
?? normalizeCustomerRuleProductRestriction({ attribute: definition.attribute, version: 0, collections: [] })
|
||||
));
|
||||
const nextDrafts = productRuleDefinitions.value.map(
|
||||
(definition) =>
|
||||
byAttribute.get(definition.attribute) ??
|
||||
normalizeCustomerRuleProductRestriction({ attribute: definition.attribute, version: 0, collections: [] })
|
||||
);
|
||||
|
||||
drafts.value = nextDrafts;
|
||||
products.value = normalized.products.sort((left, right) => left.name.localeCompare(right.name));
|
||||
savedSnapshots.value = new Map(nextDrafts.map((rule) => [rule.attribute, normalizedSnapshot(rule)]));
|
||||
};
|
||||
|
||||
const parseError = (error, fallbackKey) => (
|
||||
error?.response?.data?.data?.message
|
||||
|| error?.response?.data?.message
|
||||
|| t(fallbackKey)
|
||||
);
|
||||
const parseError = (error, fallbackMessage) =>
|
||||
error?.response?.data?.data?.message || error?.response?.data?.message || fallbackMessage;
|
||||
|
||||
const load = async () => {
|
||||
if (!canView.value) {
|
||||
@@ -87,7 +107,7 @@ const load = async () => {
|
||||
conflictAttributes.value = new Set();
|
||||
} catch (error) {
|
||||
console.error("Failed to load customer rule product restrictions", error);
|
||||
loadError.value = parseError(error, "customer_rules.configuration.errors.load");
|
||||
loadError.value = parseError(error, t("customer_rules.configuration.errors.load"));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -107,10 +127,9 @@ const visibleProducts = (rule, collection) => {
|
||||
return products.value;
|
||||
}
|
||||
|
||||
return products.value.filter((product) => (
|
||||
productLabel(product).toLocaleLowerCase().includes(search)
|
||||
|| String(product.id).includes(search)
|
||||
));
|
||||
return products.value.filter(
|
||||
(product) => productLabel(product).toLocaleLowerCase().includes(search) || String(product.id).includes(search)
|
||||
);
|
||||
};
|
||||
|
||||
const isProductSelected = (collection, productId) => collection.product_ids.includes(productId);
|
||||
@@ -222,7 +241,7 @@ const save = async (rule) => {
|
||||
} else {
|
||||
saveErrors.value = {
|
||||
...saveErrors.value,
|
||||
[rule.attribute]: parseError(error, "customer_rules.configuration.errors.save"),
|
||||
[rule.attribute]: parseError(error, t("customer_rules.configuration.errors.save")),
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
@@ -239,10 +258,13 @@ const cancel = (rule) => {
|
||||
}
|
||||
|
||||
const saved = JSON.parse(snapshot);
|
||||
replaceSavedRule(rule.attribute, normalizeCustomerRuleProductRestriction({
|
||||
attribute: rule.attribute,
|
||||
...saved,
|
||||
}));
|
||||
replaceSavedRule(
|
||||
rule.attribute,
|
||||
normalizeCustomerRuleProductRestriction({
|
||||
attribute: rule.attribute,
|
||||
...saved,
|
||||
})
|
||||
);
|
||||
saveErrors.value = { ...saveErrors.value, [rule.attribute]: "" };
|
||||
};
|
||||
|
||||
@@ -288,8 +310,10 @@ watch(
|
||||
>
|
||||
<div class="customer-rule-configuration__rule-header">
|
||||
<div>
|
||||
<h2 class="title is-5 mb-1">{{ t(`customer_rules.attributes.${rule.attribute}.label`) }}</h2>
|
||||
<p class="is-size-7 has-text-grey">{{ t(`customer_rules.attributes.${rule.attribute}.description`) }}</p>
|
||||
<h2 class="title is-5 mb-1">{{ productRuleTranslation(rule.attribute, "label") }}</h2>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ productRuleTranslation(rule.attribute, "description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<span class="tag is-light">{{ t("customer_rules.configuration.version", { version: rule.version }) }}</span>
|
||||
|
||||
@@ -361,7 +361,10 @@ test.describe("admin POS order filters", () => {
|
||||
});
|
||||
|
||||
test("preserves the department and draft customer across filter changes", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name.includes("mobile"), "Desktop draft filter coverage");
|
||||
test.skip(
|
||||
testInfo.project.name.includes("mobile") || testInfo.project.name.includes("tablet"),
|
||||
"Desktop draft filter coverage"
|
||||
);
|
||||
|
||||
await page.clock.setFixedTime(new Date("2026-07-07T10:00:00.000Z"));
|
||||
const requests = await setupAdminOrderFilters(page);
|
||||
|
||||
Reference in New Issue
Block a user