Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .agents/skills/pr-description/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ Write 1-3 concise sentences explaining the reason for the change. Tie it to the

Name affected screens, flows, platforms, users, modules, or developer workflows. Use a short sentence or compact bullets. If impact is unclear, leave a placeholder comment instead of guessing.

### Risk Classification

Add the appropriate `risk:*` label when the PR is opened. If the label is unknown, escalate instead of guessing.

- `risk:low` for config, copy, or minor UI tweaks with low blast radius.
- `risk:medium` for feature work or refactors touching multiple files.
- `risk:high` for auth, payments, migrations, or security-sensitive code.

If the branch or user request does not make the risk obvious, leave the template placeholder and call out the uncertainty in Notes.

### How did you test this?

List commands run and manual checks performed. Keep the existing checklist from the template and check only items that are supported by evidence or explicitly provided by the user.
Expand Down
19 changes: 19 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ _**Why** did you make these changes? This is your opportunity to provide the rat
_Does your code affect something downstream? Are there side effects people should know about? Tag any developers that should be kept abreast of this change._
-->

---

### Layer 2: Risk Classification

Author applies a risk label when opening the PR. When in doubt, escalate.

| Label | When to use |
| --- | --- |
| `risk:low` | Config, copy, minor UI tweaks. Low blast radius. |
| `risk:medium` | Feature work, refactors touching multiple files. |
| `risk:high` | Auth, payments, migrations, security-sensitive code. |

**Setup**

- [ ] Create `risk:low`, `risk:medium`, `risk:high` labels in the repo
- [ ] Add risk criteria to contributing guide or PR template
- [ ] Add reminder in PR template to apply the label before requesting review
- [ ] Define who can override/escalate (suggested: tech lead)

## How did you test this?

<!---
Expand Down
98 changes: 98 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copilot Instructions

## Project
React Native + Expo template maintained by Rootstrap. Production-ready starting point for mobile apps targeting iOS, Android, and Web, used as the base for client projects and internal tooling. Ships onboarding, Devise Token Auth authentication, a paginated feed, and settings out of the box.

Full details in `AGENTS.md`, `agent_docs/architecture.md`, `agent_docs/conventions.md`, and `REVIEW.md` (code review checklist) at the repo root — read these before non-trivial changes.

## Stack
- React Native 0.81, Expo SDK 54, React 19, TypeScript 5
- Expo Router (file-based routing, `src/app/`)
- NativeWind (Tailwind for React Native) + `tailwind-variants`
- TanStack Query via `react-query-kit`, Axios, `@lukemorales/query-key-factory`
- Zustand (imperative auth store) + React Context (`AuthProvider`, reactive UI auth)
- `react-hook-form` + `zod` for forms
- `react-native-mmkv` for storage, `dayjs` for dates, `expo-crypto` for IDs
- `moti` / `react-native-reanimated` for animation
- `@shopify/flash-list` for lists, `react-i18next` for i18n
- Jest + `@testing-library/react-native` for tests, Maestro for e2e
- Package manager: pnpm only (enforced via `preinstall`)

## Conventions
- Functional components only, no class components
- TypeScript strict mode — avoid `any`
- API hooks: use `createMutation` / `createQuery` from `react-query-kit`, never raw `useMutation` / `useQuery`; place at `src/api/<domain>/use-<action>.ts`
- New query domains must be registered in `src/api/query-factory.ts`
- Styling: NativeWind `className` on primitives; `tailwind-variants` for multi-state components; `StyleSheet.create` only for root layout containers
- Forms: `react-hook-form` + `zod` resolver, type via `z.infer`, no `useState` for form state, no `Alert.alert()` for validation errors
- Strings: always through `useTranslation()`, keys defined in `src/translations/en.json`
- Write camelCase in TypeScript everywhere — Axios interceptors handle camelCase↔snake_case conversion, never do it manually
- Auth/routing guards live only in `src/app/_layout.tsx` via `Stack.Protected` — never inside screen components
- Storage access only through helpers in `src/lib/storage.tsx` (`getItem`/`setItem`/`removeItem`) or auth helpers (`storeTokens`/`getTokenDetails`/`clearTokens`) — never call `storage`/`authStorage` directly
- Lists that can grow: `@shopify/flash-list`, never `FlatList`
- IDs: `expo-crypto`, never `Math.random()` / `Date.now()`
- Never hardcode env values — use `Env` from `@/lib/env`

## What to focus on in reviews
- Auth or redirect logic added inside screen components instead of `_layout.tsx`
- Manual camelCase/snake_case conversion duplicating the interceptors
- Raw `useMutation`/`useQuery` instead of `react-query-kit` factories
- Direct `storage`/`authStorage` access bypassing helper functions
- Hardcoded secrets, API keys, or environment-specific values
- Missing translation keys or dynamically constructed i18n keys
- `FlatList` used for growing/feed-style content instead of `FlashList`
- Unhandled async errors
- New dependencies not verified for Expo SDK 54 / RN 0.81 compatibility
- Accessibility issues (missing labels, keyboard navigation)

## Classify

Every code review comment must include one of the following labels:

| Label | Meaning |
|-------|---------|
| `ai:clean` | No significant issues found. The code follows the project conventions and no changes are required. |
| `ai:minor` | Minor issues such as style, readability, maintainability, naming, small performance improvements, or convention violations that are not likely to cause bugs. |
| `ai:serious` | Significant issues including security vulnerabilities, logic errors, crashes, data corruption, broken functionality, race conditions, missing error handling, or architectural violations that should be fixed before merging. |

### Review Rules

- Every review comment **must** start with one of the labels above.
- Do **not** use more than one label per comment.
- Only create review comments for actionable issues.
- If no issues are found, leave a single review comment labeled `ai:clean` indicating the review passed.

### Overall Review Classification

The **main (summary) review comment** must also include a single overall classification label representing the entire review:

- `ai:clean` — No significant issues found.
- `ai:minor` — Only minor suggestions were found.
- `ai:serious` — At least one serious issue was identified.

Determine the overall label using the highest severity found during the review:

- If any comment is `ai:serious`, the overall review must be `ai:serious`.
- Otherwise, if any comment is `ai:minor`, the overall review must be `ai:minor`.
- Otherwise, the overall review must be `ai:clean`.

The summary should begin with the overall label, for example:

```
Overall Review: ai:minor

Summary:
- 2 minor convention violations
- No security or correctness issues
```

or

```
Overall Review: ai:serious

Summary:
- Logic bug in authentication flow
- Missing async error handling
- Hardcoded environment value
```
136 changes: 136 additions & 0 deletions .github/scripts/gatekeeper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Layer 4 - Classification and Routing Gatekeeper
//
// Routing table (required approvals):
//
// Risk \ AI findings | clean | minor | serious
Comment on lines +1 to +5
// -------------------|-------|-------|--------
// low | 0 | 0 | 1
// medium | 0 | 1 | 2
// high | 1 | 2 | 2

const ROUTING_TABLE = {
low: { clean: 0, minor: 0, serious: 1 },
medium: { clean: 0, minor: 1, serious: 2 },
high: { clean: 1, minor: 2, serious: 2 },
};

// Resolve the PR number and head SHA depending on the triggering event.
async function resolvePr({ github, context, core }) {
if (context.eventName === 'pull_request' || context.eventName === 'pull_request_review') {
return {
prNumber: context.payload.pull_request.number,
headSha: context.payload.pull_request.head.sha,
};
}

if (context.eventName === 'check_suite') {
// check_suite doesn't directly reference a PR; find the associated open PR.
const headBranch = context.payload.check_suite.head_branch;

const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${headBranch}`,
});

if (prs.length === 0) {
core.info('check_suite: no open PR found for this branch, skipping.');
return null;
}

return { prNumber: prs[0].number, headSha: context.payload.check_suite.head_sha };
}

core.info(`Unhandled event: ${context.eventName}`);
return null;
}

// Determine required approvals from the risk:* and ai:* labels.
function getRoutingDecision(labelNames) {
const riskLabel = labelNames.find(l => l.startsWith('risk:'));
const riskKey = riskLabel ? riskLabel.replace('risk:', '') : null;

const aiLabel = labelNames.find(l => l.startsWith('ai:'));
const aiKey = aiLabel ? aiLabel.replace('ai:', '') : null;

if (!riskKey || !aiKey) {
// Labels not yet applied — stay pending so the PR can't slip through.
return { riskKey, aiKey, requiredApprovals: null, statusDescription: 'Waiting for risk and AI labels to be applied.' };
}

if (!ROUTING_TABLE[riskKey] || ROUTING_TABLE[riskKey][aiKey] === undefined) {
return { riskKey, aiKey, requiredApprovals: null, statusDescription: `Unknown label combination: risk:${riskKey}, ai:${aiKey}.` };
}

return { riskKey, aiKey, requiredApprovals: ROUTING_TABLE[riskKey][aiKey], statusDescription: null };
}

// Count current APPROVED reviews, keeping only the most-recent review per reviewer.
async function countApprovals({ github, context, prNumber }) {
const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});

const latestByReviewer = {};
for (const review of reviews) {
// Keep the latest review per user (reviews are returned in chronological order).
latestByReviewer[review.user.login] = review.state;
}

return Object.values(latestByReviewer).filter(s => s === 'APPROVED').length;
}

// Decide the commit status state/description from the routing decision and approval count.
function computeStatus({ requiredApprovals, statusDescription, riskKey, aiKey, approvalCount }) {
if (requiredApprovals === null) {
return { state: 'pending', description: statusDescription };
}

if (requiredApprovals === 0) {
return { state: 'success', description: `risk:${riskKey} + ai:${aiKey} → auto-merge allowed (0 approvals required).` };
}

if (approvalCount >= requiredApprovals) {
return { state: 'success', description: `risk:${riskKey} + ai:${aiKey} → approved (${approvalCount}/${requiredApprovals}).` };
}

return { state: 'pending', description: `risk:${riskKey} + ai:${aiKey} → waiting for approvals (${approvalCount}/${requiredApprovals}).` };
}

module.exports = async ({ github, context, core }) => {
const pr = await resolvePr({ github, context, core });
if (!pr) {
return;
}
const { prNumber, headSha } = pr;

const { data: prData } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});

const labelNames = prData.labels.map(l => l.name);
core.info(`PR #${prNumber} labels: ${labelNames.join(', ') || '(none)'}`);

const { riskKey, aiKey, requiredApprovals, statusDescription } = getRoutingDecision(labelNames);

const approvalCount = await countApprovals({ github, context, prNumber });
core.info(`Approvals: ${approvalCount}`);

const { state, description } = computeStatus({ requiredApprovals, statusDescription, riskKey, aiKey, approvalCount });
core.info(`Setting status: ${state} — ${description}`);

// The context name MUST match the required status check name in branch protection.
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: headSha,
state,
description,
context: 'layer4-gatekeeper',
});
};
5 changes: 5 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
. "$(dirname "$0")/common.sh"

echo '>> Running Pushgate pre-push checks...'

"$(git rev-parse --git-common-dir)/hooks/pre-push" "$@"
Comment on lines +1 to +5
Comment on lines +1 to +5
Loading
Loading