feature-custom-store-locations #4
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
# Environment variables (DO NOT COMMIT)
|
||||
.env
|
||||
.codex-local.env
|
||||
|
||||
# Node dependencies
|
||||
node_modules/
|
||||
|
||||
@ -261,6 +261,8 @@ Before editing, run this read-only intake:
|
||||
|
||||
### Push and PR coordination
|
||||
- Push the branch before opening a PR.
|
||||
- For this Gitea repo, use `docs/GITEA_PR_WORKFLOW.md` and `scripts/gitea-pr.js` for PR creation, lookup, and merge operations.
|
||||
- PR tooling must read auth from `GITEA_TOKEN`/`GITEA_BASE_URL` shell environment or ignored `.codex-local.env` only; never commit tokens or print token values.
|
||||
- Open a draft PR early for non-trivial, collision-prone, or multi-agent work once the first coherent commit exists.
|
||||
- Use the PR body as the coordination record:
|
||||
- `Owner:`
|
||||
@ -285,3 +287,4 @@ Before editing, run this read-only intake:
|
||||
- Tests run, or a clear reason tests were not run.
|
||||
- For broad branches, organize the summary by subsystem, workflow, or behavior area.
|
||||
- Do not use auto-closing keywords such as `Closes`, `Fixes`, or `Resolves`.
|
||||
- Merge PRs only after explicit operator approval, required checks, and a final `npm run pr:view -- --number <pr-number>` status check.
|
||||
|
||||
@ -14,6 +14,42 @@ exports.getUserHouseholds = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.reorderHouseholds = async (req, res) => {
|
||||
try {
|
||||
const rawHouseholdIds = req.body.household_ids || req.body.householdIds;
|
||||
|
||||
if (!Array.isArray(rawHouseholdIds)) {
|
||||
return sendError(res, 400, "household_ids must be an array");
|
||||
}
|
||||
|
||||
const householdIds = rawHouseholdIds.map((householdId) =>
|
||||
Number.parseInt(householdId, 10)
|
||||
);
|
||||
const hasInvalidId = householdIds.some(
|
||||
(householdId) => !Number.isInteger(householdId) || householdId <= 0
|
||||
);
|
||||
const hasDuplicates = new Set(householdIds).size !== householdIds.length;
|
||||
|
||||
if (hasInvalidId || hasDuplicates) {
|
||||
return sendError(res, 400, "household_ids must contain unique positive household IDs");
|
||||
}
|
||||
|
||||
const households = await householdModel.reorderUserHouseholds(req.user.id, householdIds);
|
||||
|
||||
if (!households) {
|
||||
return sendError(res, 400, "Household order must include every household you belong to");
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "Household order updated successfully",
|
||||
households,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(req, "households.reorderHouseholds", error);
|
||||
sendError(res, 500, "Failed to update household order");
|
||||
}
|
||||
};
|
||||
|
||||
// Get household details
|
||||
exports.getHousehold = async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
const pool = require("../db/pool");
|
||||
|
||||
// Get all households a user belongs to
|
||||
exports.getUserHouseholds = async (userId) => {
|
||||
const result = await pool.query(
|
||||
async function queryUserHouseholds(db, userId) {
|
||||
const result = await db.query(
|
||||
`SELECT
|
||||
h.id,
|
||||
h.name,
|
||||
@ -10,14 +9,65 @@ exports.getUserHouseholds = async (userId) => {
|
||||
h.created_at,
|
||||
hm.role,
|
||||
hm.joined_at,
|
||||
hm.household_sort_order,
|
||||
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
||||
FROM households h
|
||||
JOIN household_members hm ON h.id = hm.household_id
|
||||
WHERE hm.user_id = $1
|
||||
ORDER BY hm.joined_at DESC`,
|
||||
ORDER BY hm.household_sort_order ASC NULLS LAST, hm.joined_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Get all households a user belongs to
|
||||
exports.getUserHouseholds = async (userId) => {
|
||||
return queryUserHouseholds(pool, userId);
|
||||
};
|
||||
|
||||
exports.reorderUserHouseholds = async (userId, householdIds) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const membershipResult = await client.query(
|
||||
`SELECT household_id
|
||||
FROM household_members
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const currentIds = membershipResult.rows.map((row) => Number(row.household_id));
|
||||
const currentIdSet = new Set(currentIds);
|
||||
|
||||
if (
|
||||
householdIds.length !== currentIds.length ||
|
||||
householdIds.some((householdId) => !currentIdSet.has(householdId))
|
||||
) {
|
||||
await client.query("ROLLBACK");
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [index, householdId] of householdIds.entries()) {
|
||||
await client.query(
|
||||
`UPDATE household_members
|
||||
SET household_sort_order = $1
|
||||
WHERE user_id = $2
|
||||
AND household_id = $3`,
|
||||
[index, userId, householdId]
|
||||
);
|
||||
}
|
||||
|
||||
const households = await queryUserHouseholds(client, userId);
|
||||
await client.query("COMMIT");
|
||||
return households;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
// Get household by ID (with member check)
|
||||
|
||||
@ -16,6 +16,7 @@ const { upload, processImage } = require("../middleware/image");
|
||||
// Public routes (authenticated only)
|
||||
router.get("/", auth, controller.getUserHouseholds);
|
||||
router.post("/", auth, controller.createHousehold);
|
||||
router.patch("/order", auth, controller.reorderHouseholds);
|
||||
router.post("/join/:inviteCode", auth, controller.joinHousehold);
|
||||
|
||||
// Household-scoped routes (member access required)
|
||||
|
||||
@ -43,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({
|
||||
joinHousehold: jest.fn(),
|
||||
refreshInviteCode: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
reorderHouseholds: jest.fn(),
|
||||
updateHousehold: jest.fn(),
|
||||
updateMemberRole: jest.fn(),
|
||||
}));
|
||||
|
||||
75
backend/tests/household.model.test.js
Normal file
75
backend/tests/household.model.test.js
Normal file
@ -0,0 +1,75 @@
|
||||
jest.mock("../db/pool", () => ({
|
||||
connect: jest.fn(),
|
||||
query: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require("../db/pool");
|
||||
const Household = require("../models/household.model");
|
||||
|
||||
describe("household.model household ordering", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("loads households using the user's saved sort order", async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rows: [{ id: 2, name: "Second", household_sort_order: 0 }],
|
||||
});
|
||||
|
||||
const households = await Household.getUserHouseholds(9);
|
||||
|
||||
expect(households).toEqual([{ id: 2, name: "Second", household_sort_order: 0 }]);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("ORDER BY hm.household_sort_order ASC NULLS LAST"),
|
||||
[9]
|
||||
);
|
||||
});
|
||||
|
||||
test("persists a full household order for the current user", async () => {
|
||||
const client = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ rows: [{ household_id: 1 }, { household_id: 2 }] })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ rows: [{ id: 2 }, { id: 1 }] })
|
||||
.mockResolvedValueOnce({}),
|
||||
release: jest.fn(),
|
||||
};
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
const households = await Household.reorderUserHouseholds(9, [2, 1]);
|
||||
|
||||
expect(households).toEqual([{ id: 2 }, { id: 1 }]);
|
||||
expect(client.query).toHaveBeenNthCalledWith(1, "BEGIN");
|
||||
expect(client.query).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringContaining("SET household_sort_order = $1"),
|
||||
[0, 9, 2]
|
||||
);
|
||||
expect(client.query).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.stringContaining("SET household_sort_order = $1"),
|
||||
[1, 9, 1]
|
||||
);
|
||||
expect(client.query).toHaveBeenLastCalledWith("COMMIT");
|
||||
expect(client.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects an order that does not match the user's memberships", async () => {
|
||||
const client = {
|
||||
query: jest.fn()
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ rows: [{ household_id: 1 }] })
|
||||
.mockResolvedValueOnce({}),
|
||||
release: jest.fn(),
|
||||
};
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
const households = await Household.reorderUserHouseholds(9, [1, 2]);
|
||||
|
||||
expect(households).toBeNull();
|
||||
expect(client.query).toHaveBeenNthCalledWith(3, "ROLLBACK");
|
||||
expect(client.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
jest.mock("../models/household.model", () => ({
|
||||
getUserRole: jest.fn(),
|
||||
reorderUserHouseholds: jest.fn(),
|
||||
transferOwnership: jest.fn(),
|
||||
updateMemberRole: jest.fn(),
|
||||
}));
|
||||
@ -86,3 +87,72 @@ describe("households.controller updateMemberRole", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("households.controller reorderHouseholds", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
householdModel.reorderUserHouseholds.mockResolvedValue([
|
||||
{ id: 3, name: "Third" },
|
||||
{ id: 1, name: "First" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("updates the current user's household order", async () => {
|
||||
const req = {
|
||||
body: { household_ids: [3, 1] },
|
||||
user: { id: 9 },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.reorderHouseholds(req, res);
|
||||
|
||||
expect(householdModel.reorderUserHouseholds).toHaveBeenCalledWith(9, [3, 1]);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: "Household order updated successfully",
|
||||
households: [
|
||||
{ id: 3, name: "Third" },
|
||||
{ id: 1, name: "First" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects duplicate household IDs", async () => {
|
||||
const req = {
|
||||
body: { household_ids: [3, 3] },
|
||||
user: { id: 9 },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.reorderHouseholds(req, res);
|
||||
|
||||
expect(householdModel.reorderUserHouseholds).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "household_ids must contain unique positive household IDs",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects orders that do not match current memberships", async () => {
|
||||
householdModel.reorderUserHouseholds.mockResolvedValue(null);
|
||||
const req = {
|
||||
body: { household_ids: [999] },
|
||||
user: { id: 9 },
|
||||
};
|
||||
const res = createResponse();
|
||||
|
||||
await controller.reorderHouseholds(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Household order must include every household you belong to",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -43,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({
|
||||
joinHousehold: jest.fn(),
|
||||
refreshInviteCode: jest.fn(),
|
||||
removeMember: jest.fn(),
|
||||
reorderHouseholds: jest.fn((req, res) => res.json({ message: "ordered" })),
|
||||
updateHousehold: jest.fn(),
|
||||
updateMemberRole: jest.fn(),
|
||||
}));
|
||||
@ -87,6 +88,7 @@ jest.mock("../controllers/stores.controller", () => ({
|
||||
const express = require("express");
|
||||
const request = require("supertest");
|
||||
const router = require("../routes/households.routes");
|
||||
const householdsController = require("../controllers/households.controller");
|
||||
const storesController = require("../controllers/stores.controller");
|
||||
|
||||
describe("store location routes", () => {
|
||||
@ -106,6 +108,15 @@ describe("store location routes", () => {
|
||||
expect(storesController.getHouseholdStores).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("users can reorder their household switcher list", async () => {
|
||||
const response = await request(app)
|
||||
.patch("/households/order")
|
||||
.send({ household_ids: [3, 1, 2] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(householdsController.reorderHouseholds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("members cannot create household stores", async () => {
|
||||
const response = await request(app)
|
||||
.post("/households/1/stores")
|
||||
|
||||
71
docs/GITEA_PR_WORKFLOW.md
Normal file
71
docs/GITEA_PR_WORKFLOW.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Gitea PR Workflow
|
||||
|
||||
Use this workflow when creating or merging PRs for this repo. It is designed for Codex and local operators to use the same commands without storing secrets in git.
|
||||
|
||||
## One-time token setup
|
||||
|
||||
Create a Gitea access token with repository pull request permissions, then set it only in the shell or user environment:
|
||||
|
||||
```powershell
|
||||
$env:GITEA_BASE_URL = "http://192.168.7.78:3000"
|
||||
$env:GITEA_TOKEN = "<token>"
|
||||
```
|
||||
|
||||
For Codex sandbox sessions, use the ignored local env file when inherited environment variables are not visible:
|
||||
|
||||
```powershell
|
||||
@"
|
||||
GITEA_BASE_URL=http://192.168.7.78:3000
|
||||
GITEA_TOKEN=<token>
|
||||
"@ | Set-Content .codex-local.env
|
||||
```
|
||||
|
||||
Do not commit tokens, paste them into docs, or print them in logs.
|
||||
|
||||
Check auth:
|
||||
|
||||
```powershell
|
||||
npm run pr:auth
|
||||
```
|
||||
|
||||
## Create a PR
|
||||
|
||||
1. Push the branch first.
|
||||
2. Inspect the cumulative branch diff against the PR target:
|
||||
|
||||
```powershell
|
||||
git log <base>..HEAD --oneline --decorate
|
||||
git diff --stat <base>..HEAD
|
||||
```
|
||||
|
||||
3. Write the PR body to a temporary local file. Include the coordination record required by `PROJECT_INSTRUCTIONS.md`.
|
||||
4. Create the PR:
|
||||
|
||||
```powershell
|
||||
npm run pr:create -- --base <base> --title "<title>" --body-file <body-file>
|
||||
```
|
||||
|
||||
The helper checks for an existing open PR with the same base/head and returns it instead of creating a duplicate.
|
||||
If the PR body needs to be changed after creation, update it from a body file:
|
||||
|
||||
```powershell
|
||||
npm run pr:update -- --number <pr-number> --body-file <body-file>
|
||||
```
|
||||
|
||||
For stacked work, pass the parent PR branch as `<base>`. For standalone work, pass `main`.
|
||||
|
||||
## View or merge a PR
|
||||
|
||||
View:
|
||||
|
||||
```powershell
|
||||
npm run pr:view -- --number <pr-number>
|
||||
```
|
||||
|
||||
Merge after explicit operator approval and required checks:
|
||||
|
||||
```powershell
|
||||
npm run pr:merge -- --number <pr-number> --method merge --delete-branch --yes
|
||||
```
|
||||
|
||||
The helper refuses to merge without `--yes`. Use `--method squash` or `--method rebase` only when that is the intended repo workflow.
|
||||
@ -28,6 +28,21 @@ The active grocery flow is scoped by household-owned store locations, not the le
|
||||
|
||||
Owners/admins manage stores, locations, zones, and catalog deletion. Members can add/update list items and catalog item details.
|
||||
|
||||
### Household Switcher Order
|
||||
|
||||
The current user can persist their own household switcher order:
|
||||
|
||||
- Update household order: `PATCH /households/order`
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"household_ids": [3, 1, 2]
|
||||
}
|
||||
```
|
||||
|
||||
The list must contain each household the current user belongs to exactly once. The response returns the reordered household list.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
@ -5,6 +5,12 @@ import api from "./axios";
|
||||
*/
|
||||
export const getUserHouseholds = () => api.get("/households");
|
||||
|
||||
/**
|
||||
* Update the current user's household switcher order
|
||||
*/
|
||||
export const reorderHouseholds = (householdIds) =>
|
||||
api.patch("/households/order", { household_ids: householdIds });
|
||||
|
||||
/**
|
||||
* Get details of a specific household
|
||||
*/
|
||||
|
||||
@ -3,14 +3,12 @@ export default function ListSearchInput({ value, onChange, resultCount, totalCou
|
||||
|
||||
return (
|
||||
<div className="glist-search">
|
||||
<label className="glist-search-label" htmlFor="grocery-list-search">
|
||||
Search list
|
||||
</label>
|
||||
<div className="glist-search-row">
|
||||
<input
|
||||
id="grocery-list-search"
|
||||
className="glist-search-input"
|
||||
type="search"
|
||||
aria-label="Search list"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Search list"
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import "../../styles/components/HouseholdSwitcher.css";
|
||||
import CreateJoinHousehold from "../manage/CreateJoinHousehold";
|
||||
|
||||
@ -8,10 +10,13 @@ export default function HouseholdSwitcher() {
|
||||
households,
|
||||
activeHousehold,
|
||||
setActiveHousehold,
|
||||
reorderHouseholds,
|
||||
loading,
|
||||
hasLoaded,
|
||||
} = useContext(HouseholdContext);
|
||||
const toast = useActionToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
const [showCreateJoin, setShowCreateJoin] = useState(false);
|
||||
|
||||
if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) {
|
||||
@ -49,6 +54,28 @@ export default function HouseholdSwitcher() {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleMove = async (household, currentIndex, direction) => {
|
||||
const nextIndex = currentIndex + direction;
|
||||
if (nextIndex < 0 || nextIndex >= households.length || isReordering) return;
|
||||
|
||||
const nextHouseholds = [...households];
|
||||
[nextHouseholds[currentIndex], nextHouseholds[nextIndex]] = [
|
||||
nextHouseholds[nextIndex],
|
||||
nextHouseholds[currentIndex],
|
||||
];
|
||||
|
||||
setIsReordering(true);
|
||||
try {
|
||||
await reorderHouseholds(nextHouseholds.map((nextHousehold) => nextHousehold.id));
|
||||
toast.success("Updated household order", `Moved ${household.name}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to update household order");
|
||||
toast.error("Household order failed", `Household order failed: ${message}`);
|
||||
} finally {
|
||||
setIsReordering(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="household-switcher">
|
||||
<button
|
||||
@ -65,18 +92,48 @@ export default function HouseholdSwitcher() {
|
||||
<>
|
||||
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
||||
<div className="household-switcher-dropdown">
|
||||
{households.map((household) => (
|
||||
<button
|
||||
{households.map((household, index) => (
|
||||
<div
|
||||
key={household.id}
|
||||
className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`}
|
||||
className={
|
||||
`household-option-row ${household.id === activeHousehold.id ? "active" : ""}`
|
||||
}
|
||||
>
|
||||
<button
|
||||
className="household-option"
|
||||
type="button"
|
||||
onClick={() => handleSelect(household)}
|
||||
>
|
||||
{household.name}
|
||||
{household.id === activeHousehold.id && (
|
||||
<span className="check-mark">✓</span>
|
||||
)}
|
||||
<span className="household-option-name">{household.name}</span>
|
||||
</button>
|
||||
{households.length > 1 && (
|
||||
<div
|
||||
className="household-reorder-controls"
|
||||
aria-label={`Reorder ${household.name}`}
|
||||
>
|
||||
<button
|
||||
className="household-reorder-button"
|
||||
type="button"
|
||||
onClick={() => handleMove(household, index, -1)}
|
||||
disabled={index === 0 || isReordering}
|
||||
aria-label={`Move ${household.name} up`}
|
||||
title={`Move ${household.name} up`}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
className="household-reorder-button"
|
||||
type="button"
|
||||
onClick={() => handleMove(household, index, 1)}
|
||||
disabled={index === households.length - 1 || isReordering}
|
||||
aria-label={`Move ${household.name} down`}
|
||||
title={`Move ${household.name} down`}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="household-divider"></div>
|
||||
<button
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households';
|
||||
import {
|
||||
createHousehold as createHouseholdApi,
|
||||
getUserHouseholds,
|
||||
reorderHouseholds as reorderHouseholdsApi,
|
||||
} from '../api/households';
|
||||
import { isTransientApiError } from '../api/offlineCache';
|
||||
import { AuthContext } from './AuthContext';
|
||||
|
||||
@ -14,6 +18,7 @@ export const HouseholdContext = createContext({
|
||||
setActiveHousehold: () => { },
|
||||
refreshHouseholds: () => { },
|
||||
createHousehold: () => { },
|
||||
reorderHouseholds: () => { },
|
||||
});
|
||||
|
||||
export const HouseholdProvider = ({ children }) => {
|
||||
@ -121,6 +126,57 @@ export const HouseholdProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const reorderHouseholds = async (orderedHouseholdIds) => {
|
||||
const householdById = new Map(
|
||||
households.map((household) => [String(household.id), household])
|
||||
);
|
||||
const nextHouseholds = orderedHouseholdIds
|
||||
.map((householdId) => householdById.get(String(householdId)))
|
||||
.filter(Boolean);
|
||||
|
||||
if (nextHouseholds.length !== households.length) {
|
||||
throw new Error("Household order is out of date");
|
||||
}
|
||||
|
||||
const previousHouseholds = households;
|
||||
setHouseholds(nextHouseholds);
|
||||
if (activeHousehold) {
|
||||
const nextActiveHousehold = nextHouseholds.find(
|
||||
(household) => String(household.id) === String(activeHousehold.id)
|
||||
);
|
||||
if (nextActiveHousehold) {
|
||||
setActiveHouseholdState(nextActiveHousehold);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await reorderHouseholdsApi(orderedHouseholdIds);
|
||||
const savedHouseholds = Array.isArray(response.data.households)
|
||||
? response.data.households
|
||||
: [];
|
||||
|
||||
if (savedHouseholds.length > 0) {
|
||||
setHouseholds(savedHouseholds);
|
||||
if (activeHousehold) {
|
||||
const savedActiveHousehold = savedHouseholds.find(
|
||||
(household) => String(household.id) === String(activeHousehold.id)
|
||||
);
|
||||
if (savedActiveHousehold) {
|
||||
setActiveHouseholdState(savedActiveHousehold);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return savedHouseholds;
|
||||
} catch (err) {
|
||||
setHouseholds(previousHouseholds);
|
||||
if (activeHousehold) {
|
||||
setActiveHouseholdState(activeHousehold);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
households,
|
||||
activeHousehold,
|
||||
@ -130,6 +186,7 @@ export const HouseholdProvider = ({ children }) => {
|
||||
setActiveHousehold,
|
||||
refreshHouseholds: loadHouseholds,
|
||||
createHousehold,
|
||||
reorderHouseholds,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -70,8 +70,10 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
right: auto;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: min(320px, calc(100vw - 2rem));
|
||||
overflow: hidden;
|
||||
background: var(--card-bg);
|
||||
border: 2px solid var(--border);
|
||||
@ -80,15 +82,32 @@
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.household-option-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 48px;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.household-option-row:hover {
|
||||
background: var(--button-secondary-bg);
|
||||
}
|
||||
|
||||
.household-option-row.active {
|
||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
|
||||
.household-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--card-bg);
|
||||
padding: 0.875rem 0.625rem 0.875rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
@ -96,25 +115,59 @@
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.household-option:last-child {
|
||||
border-bottom: none;
|
||||
.household-option-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.household-option:hover {
|
||||
background: var(--button-secondary-bg);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.household-option.active {
|
||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
.household-option-row.active .household-option {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
.household-reorder-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
gap: 0.125rem;
|
||||
width: 28px;
|
||||
padding: 0.25rem 0.25rem 0.25rem 0;
|
||||
}
|
||||
|
||||
.household-reorder-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.55rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.household-reorder-button:hover:not(:disabled),
|
||||
.household-reorder-button:focus-visible {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.household-reorder-button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.household-divider {
|
||||
@ -124,6 +177,7 @@
|
||||
}
|
||||
|
||||
.create-household-btn {
|
||||
border-bottom: none;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@ -109,14 +109,14 @@
|
||||
|
||||
/* Classification Groups */
|
||||
.glist-classification-group {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.glist-classification-header {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-primary);
|
||||
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
|
||||
margin: var(--spacing-md) 0 0 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-primary-light);
|
||||
border-left: var(--border-width-thick) solid var(--color-primary);
|
||||
@ -232,6 +232,10 @@
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.glist-classification-group .glist-ul {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.glist-li {
|
||||
background: var(--color-bg-surface);
|
||||
border: var(--border-width-thin) solid var(--color-border-light);
|
||||
@ -247,6 +251,14 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.glist-classification-group .glist-li {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.glist-classification-group .glist-li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.glist-item-layout {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
@ -358,14 +370,6 @@
|
||||
margin: var(--spacing-xs) 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.glist-search-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.glist-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -33,8 +33,8 @@ test("selected household stays active after refreshing on settings and home page
|
||||
];
|
||||
|
||||
const storesByHousehold = {
|
||||
1: [{ id: 101, name: "Costco", is_default: true }],
|
||||
2: [{ id: 201, name: "Trader Joe's", is_default: true }],
|
||||
1: [{ id: 101, household_store_id: 1001, name: "Costco", is_default: true }],
|
||||
2: [{ id: 201, household_store_id: 2001, name: "Trader Joe's", is_default: true }],
|
||||
};
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
@ -45,8 +45,8 @@ test("selected household stays active after refreshing on settings and home page
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/*", async (route) => {
|
||||
const householdId = Number(route.request().url().split("/").pop());
|
||||
await page.route("**/households/*/stores", async (route) => {
|
||||
const householdId = Number(route.request().url().match(/households\/(\d+)\/stores/)?.[1]);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -56,7 +56,15 @@ test("selected household stays active after refreshing on settings and home page
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/*/stores/*/list/recent", async (route) => {
|
||||
await page.route("**/households/*/locations/*/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/*/locations/*/list/recent", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -64,7 +72,7 @@ test("selected household stays active after refreshing on settings and home page
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/*/stores/*/list", async (route) => {
|
||||
await page.route("**/households/*/locations/*/list", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -84,8 +92,20 @@ test("selected household stays active after refreshing on settings and home page
|
||||
|
||||
await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Alpha Home" }).click();
|
||||
await page.getByRole("button", { name: "Bravo Home" }).click();
|
||||
const householdTrigger = page.locator(".household-switcher-toggle");
|
||||
await expect(householdTrigger).toContainText("Alpha Home");
|
||||
await householdTrigger.click();
|
||||
const householdDropdown = page.locator(".household-switcher-dropdown");
|
||||
await expect(householdDropdown).toBeVisible();
|
||||
await expect(page.locator(".check-mark")).toHaveCount(0);
|
||||
|
||||
const triggerBox = await householdTrigger.boundingBox();
|
||||
const dropdownBox = await householdDropdown.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(dropdownBox).not.toBeNull();
|
||||
expect(Math.abs((dropdownBox?.x ?? 0) - (triggerBox?.x ?? 0))).toBeLessThan(1);
|
||||
|
||||
await page.getByRole("button", { name: "Bravo Home", exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2");
|
||||
|
||||
@ -19,6 +19,11 @@
|
||||
"db:migrate:new": "node scripts/db-migrate-new.js",
|
||||
"db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write",
|
||||
"db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale",
|
||||
"pr:auth": "node scripts/gitea-pr.js auth-check",
|
||||
"pr:create": "node scripts/gitea-pr.js create",
|
||||
"pr:view": "node scripts/gitea-pr.js view",
|
||||
"pr:update": "node scripts/gitea-pr.js update",
|
||||
"pr:merge": "node scripts/gitea-pr.js merge",
|
||||
"test": "jest --runInBand",
|
||||
"test:e2e": "npm --prefix frontend run test:e2e --",
|
||||
"test:e2e:headed": "npm --prefix frontend run test:e2e:headed --",
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE household_members
|
||||
ADD COLUMN IF NOT EXISTS household_sort_order INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_household_members_user_sort_order
|
||||
ON household_members(user_id, household_sort_order, joined_at DESC);
|
||||
|
||||
COMMIT;
|
||||
379
scripts/gitea-pr.js
Normal file
379
scripts/gitea-pr.js
Normal file
@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
|
||||
loadLocalEnv();
|
||||
|
||||
const DEFAULT_REMOTE = "origin";
|
||||
const DEFAULT_SSH_HTTP_PORT = process.env.GITEA_PORT || "3000";
|
||||
|
||||
function loadLocalEnv() {
|
||||
const envPath = ".codex-local.env";
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = fs.readFileSync(envPath, "utf8").split(/\r?\n/);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1].replace(/^\uFEFF/, "");
|
||||
let value = match[2].trim();
|
||||
const isQuoted =
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"));
|
||||
|
||||
if (isQuoted) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Gitea PR helper
|
||||
|
||||
Environment:
|
||||
GITEA_TOKEN Required for API calls. Never commit this value.
|
||||
GITEA_BASE_URL Optional, e.g. http://192.168.7.78:3000.
|
||||
GITEA_OWNER Optional repo owner override.
|
||||
GITEA_REPO Optional repo name override.
|
||||
|
||||
Commands:
|
||||
auth-check
|
||||
Verify the token can authenticate with Gitea.
|
||||
|
||||
create --base <branch> [--head <branch>] --title <title> [--body-file <path> | --body <text>]
|
||||
Create a PR, or return the existing open PR for the same base/head.
|
||||
|
||||
view --number <pr-number>
|
||||
Print basic PR status.
|
||||
|
||||
update --number <pr-number> [--title <title>] [--body-file <path> | --body <text>]
|
||||
Update a PR title/body. Prefer --body-file for multi-line PR bodies.
|
||||
|
||||
merge --number <pr-number> --yes [--method merge|squash|rebase] [--delete-branch]
|
||||
Merge a PR. The --yes flag is required to avoid accidental merges.
|
||||
|
||||
Examples:
|
||||
npm run pr:auth
|
||||
npm run pr:create -- --base feature-custom-store-locations --title "Allow household switcher reordering" --body-file pr-body.md
|
||||
npm run pr:update -- --number 12 --body-file pr-body.md
|
||||
npm run pr:view -- --number 12
|
||||
npm run pr:merge -- --number 12 --method merge --delete-branch --yes
|
||||
`);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function runGit(args) {
|
||||
return execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function parseFlags(argv) {
|
||||
const flags = {};
|
||||
const positionals = [];
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (!arg.startsWith("--")) {
|
||||
positionals.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = arg.slice(2);
|
||||
const next = argv[index + 1];
|
||||
|
||||
if (!next || next.startsWith("--")) {
|
||||
flags[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
flags[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return { flags, positionals };
|
||||
}
|
||||
|
||||
function requiredFlag(flags, key) {
|
||||
const value = flags[key];
|
||||
if (!value || value === true) {
|
||||
fail(`Missing required --${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function currentBranch() {
|
||||
return runGit(["branch", "--show-current"]);
|
||||
}
|
||||
|
||||
function parseRemoteUrl(remoteUrl) {
|
||||
const normalized = remoteUrl.replace(/\.git$/, "");
|
||||
|
||||
const httpMatch = normalized.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)$/);
|
||||
if (httpMatch) {
|
||||
const [, host, owner, repo] = httpMatch;
|
||||
return { host, owner, repo, scheme: remoteUrl.startsWith("https://") ? "https" : "http" };
|
||||
}
|
||||
|
||||
const sshMatch = normalized.match(/^(?:ssh:\/\/)?[^@]+@([^:/]+)(?::\d+)?[:/]([^/]+)\/([^/]+)$/);
|
||||
if (sshMatch) {
|
||||
const [, host, owner, repo] = sshMatch;
|
||||
return { host, owner, repo, scheme: "ssh" };
|
||||
}
|
||||
|
||||
fail(`Could not parse git remote URL: ${remoteUrl}`);
|
||||
}
|
||||
|
||||
function repoConfig() {
|
||||
const remoteUrl = runGit(["remote", "get-url", DEFAULT_REMOTE]);
|
||||
const parsed = parseRemoteUrl(remoteUrl);
|
||||
const baseUrl =
|
||||
process.env.GITEA_BASE_URL ||
|
||||
process.env.GITEA_URL ||
|
||||
(parsed.scheme === "ssh"
|
||||
? `http://${parsed.host}:${DEFAULT_SSH_HTTP_PORT}`
|
||||
: `${parsed.scheme}://${parsed.host}`);
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/$/, ""),
|
||||
owner: process.env.GITEA_OWNER || parsed.owner,
|
||||
repo: process.env.GITEA_REPO || parsed.repo,
|
||||
};
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
const token = process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN;
|
||||
if (!token) {
|
||||
fail(
|
||||
"Missing GITEA_TOKEN. Create a Gitea access token with repository pull request permissions and set it in the shell."
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function apiRequest(method, route, body) {
|
||||
const { baseUrl } = repoConfig();
|
||||
const url = `${baseUrl}/api/v1${route}`;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `token ${getToken()}`,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const parsed = text ? safeJson(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
parsed?.message ||
|
||||
parsed?.error ||
|
||||
text ||
|
||||
`${response.status} ${response.statusText}`;
|
||||
fail(`Gitea API ${method} ${route} failed: ${response.status} ${message}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function safeJson(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
|
||||
function repoRoute(pathname) {
|
||||
const { owner, repo } = repoConfig();
|
||||
return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${pathname}`;
|
||||
}
|
||||
|
||||
async function listOpenPulls() {
|
||||
const pulls = [];
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const batch = await apiRequest("GET", repoRoute(`/pulls?state=open&limit=50&page=${page}`));
|
||||
pulls.push(...batch);
|
||||
|
||||
if (batch.length < 50) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return pulls;
|
||||
}
|
||||
|
||||
async function findOpenPull(base, head) {
|
||||
const pulls = await listOpenPulls();
|
||||
return pulls.find((pull) => {
|
||||
const headLabels = [pull.head?.ref, pull.head?.label].filter(Boolean);
|
||||
return (
|
||||
pull.base?.ref === base &&
|
||||
headLabels.some((label) => label === head || label.endsWith(`:${head}`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(flags) {
|
||||
if (flags["body-file"]) {
|
||||
return fs.readFileSync(flags["body-file"], "utf8");
|
||||
}
|
||||
return typeof flags.body === "string" ? flags.body : "";
|
||||
}
|
||||
|
||||
async function authCheck() {
|
||||
const user = await apiRequest("GET", "/user");
|
||||
console.log(`Authenticated as ${user.login || user.username || user.full_name || "Gitea user"}`);
|
||||
}
|
||||
|
||||
async function createPull(flags) {
|
||||
const base = requiredFlag(flags, "base");
|
||||
const head = flags.head && flags.head !== true ? flags.head : currentBranch();
|
||||
const title = requiredFlag(flags, "title");
|
||||
const body = readBody(flags);
|
||||
|
||||
const existing = await findOpenPull(base, head);
|
||||
if (existing) {
|
||||
console.log(`Existing PR #${existing.number}: ${existing.html_url || existing.url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pull = await apiRequest("POST", repoRoute("/pulls"), {
|
||||
base,
|
||||
head,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
|
||||
console.log(`Created PR #${pull.number}: ${pull.html_url || pull.url}`);
|
||||
}
|
||||
|
||||
async function viewPull(flags) {
|
||||
const number = requiredFlag(flags, "number");
|
||||
const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`));
|
||||
console.log(
|
||||
[
|
||||
`PR #${pull.number}: ${pull.title}`,
|
||||
`URL: ${pull.html_url || pull.url}`,
|
||||
`State: ${pull.state}${pull.merged ? " (merged)" : ""}`,
|
||||
`Base: ${pull.base?.ref || "(unknown)"}`,
|
||||
`Head: ${pull.head?.ref || "(unknown)"}`,
|
||||
`Mergeable: ${String(pull.mergeable)}`,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
async function updatePull(flags) {
|
||||
const number = requiredFlag(flags, "number");
|
||||
const payload = {};
|
||||
|
||||
if (flags.title && flags.title !== true) {
|
||||
payload.title = flags.title;
|
||||
}
|
||||
|
||||
if (flags["body-file"] || typeof flags.body === "string") {
|
||||
payload.body = readBody(flags);
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
fail("Nothing to update; pass --title, --body, or --body-file");
|
||||
}
|
||||
|
||||
const pull = await apiRequest(
|
||||
"PATCH",
|
||||
repoRoute(`/pulls/${encodeURIComponent(number)}`),
|
||||
payload
|
||||
);
|
||||
|
||||
console.log(`Updated PR #${pull.number}: ${pull.html_url || pull.url}`);
|
||||
}
|
||||
|
||||
async function mergePull(flags) {
|
||||
const number = requiredFlag(flags, "number");
|
||||
if (!flags.yes) {
|
||||
fail("Refusing to merge without --yes");
|
||||
}
|
||||
|
||||
const method = flags.method && flags.method !== true ? flags.method : "merge";
|
||||
const allowedMethods = new Set(["merge", "squash", "rebase"]);
|
||||
if (!allowedMethods.has(method)) {
|
||||
fail("--method must be one of: merge, squash, rebase");
|
||||
}
|
||||
|
||||
const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`));
|
||||
if (pull.state !== "open") {
|
||||
fail(`Refusing to merge PR #${number}; state is ${pull.state}`);
|
||||
}
|
||||
if (pull.merged) {
|
||||
fail(`PR #${number} is already merged`);
|
||||
}
|
||||
|
||||
await apiRequest("POST", repoRoute(`/pulls/${encodeURIComponent(number)}/merge`), {
|
||||
Do: method,
|
||||
delete_branch_after_merge: Boolean(flags["delete-branch"]),
|
||||
});
|
||||
|
||||
console.log(`Merged PR #${number} with method ${method}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const { flags } = parseFlags(rest);
|
||||
|
||||
switch (command) {
|
||||
case undefined:
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
usage();
|
||||
break;
|
||||
case "auth-check":
|
||||
await authCheck();
|
||||
break;
|
||||
case "create":
|
||||
await createPull(flags);
|
||||
break;
|
||||
case "view":
|
||||
await viewPull(flags);
|
||||
break;
|
||||
case "update":
|
||||
await updatePull(flags);
|
||||
break;
|
||||
case "merge":
|
||||
await mergePull(flags);
|
||||
break;
|
||||
default:
|
||||
fail(`Unknown command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
fail(error.stack || error.message || String(error));
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user