Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ run_extra_dir() {

echo "Running pre-commit checks ($mode_label)... ${REPO_ROOT}" >&2
python_changed=0
frontend_changed=0

while IFS= read -r path; do
[ -n "$path" ] || continue
Expand All @@ -75,12 +76,22 @@ while IFS= read -r path; do
;;
.editorconfig)
python_changed=1
frontend_changed=1
;;
ui/*)
frontend_changed=1
;;
esac
done <<EOF
$selected_files
EOF

if [ "$frontend_changed" -eq 1 ]; then
echo "Preparing frontend..." >&2
NODE_ENV=development bun --cwd=./ui run nuxt prepare --envName=development
echo "Frontend preparation passed." >&2
fi

if [ "$python_changed" -eq 1 ]; then
run_step "Python: Formatter" uv run ruff format --check .
run_step "Python: Linter" uv run ruff check .
Expand All @@ -97,8 +108,21 @@ if [ "$python_changed" -eq 1 ]; then
fi
fi

if [ "$frontend_changed" -eq 1 ]; then
run_step "Frontend: Formatter" bun --cwd=./ui run format:check
run_step "Frontend: Linter" bun --cwd=./ui run lint:ci
run_step "Frontend: Tests" bun --cwd=./ui test
run_step "Frontend: TSC" bun --cwd=./ui run lint:tsc
fi

extra_dir=$(git rev-parse --git-path hooks)/pre-commit.d
run_extra_dir "Global" "$extra_dir"
if [ "$python_changed" -eq 1 ]; then
run_extra_dir "Backend" "$extra_dir/backend"
fi
if [ "$frontend_changed" -eq 1 ]; then
run_extra_dir "Frontend" "$extra_dir/frontend"
fi

if [ "$checks_started" -eq 0 ]; then
echo "No checks apply to the $mode_label files."
Expand Down
42 changes: 32 additions & 10 deletions .github/scripts/generate_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from pathlib import Path
from typing import TypedDict

CO_AUTHORED_BY = re.compile(r"^Co-authored-by:\s", re.IGNORECASE)


class Commit(TypedDict):
sha: str
Expand All @@ -29,42 +31,58 @@ def git(repo: Path, *args: str) -> str:
return subprocess.check_output(["git", "-C", str(repo), *args], text=True, encoding="utf-8") # noqa: S607


def commits_between(repo: Path, end: str, excluded: list[str]) -> list[Commit]:
def commits_between(repo: Path, refs: list[str], excluded: list[str]) -> list[Commit]:
output = git(
repo, "log", "--topo-order", "--no-merges", "-z", "--format=%H%x00%an%x00%cI%x00%B", end, "--not", *excluded
repo, "log", "--topo-order", "--no-merges", "-z", "--format=%H%x00%an%x00%cI%x00%B", *refs, "--not", *excluded
)
fields = output.split("\0")
return [
Commit(sha=sha[:8], full_sha=sha, author=author, date=date, message=message.strip())
Commit(
sha=sha[:8],
full_sha=sha,
author=author,
date=date,
message="\n".join(line for line in message.splitlines() if not CO_AUTHORED_BY.match(line)).strip(),
)
for sha, author, date, message in zip(fields[0::4], fields[1::4], fields[2::4], fields[3::4], strict=False)
]


def generate_changelog(repo: Path) -> list[ChangeSet]:
def generate_changelog(repo: Path, *, ref: str = "HEAD", include_ref: str | None = None) -> list[ChangeSet]:
if git(repo, "rev-parse", "--is-shallow-repository").strip() == "true":
msg = "Cannot generate complete commit history from a shallow checkout. Fetch full history and tags first."
raise RuntimeError(msg)

history = git(repo, "rev-list", "--topo-order", "HEAD").splitlines()
history = git(repo, "rev-list", "--topo-order", ref).splitlines()
positions = {sha: index for index, sha in enumerate(history)}
tags = [
(name, git(repo, "rev-parse", f"refs/tags/{name}^{{commit}}").strip())
for name in git(repo, "tag", "--merged", "HEAD").splitlines()
for name in git(repo, "tag", "--merged", history[0]).splitlines()
if re.fullmatch(r"v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", name)
]
# Commit timestamps can move backwards. Release ranges follow ancestry instead.
tags.sort(key=lambda tag: (positions[tag[1]], tag[0]))
unreleased_refs = [history[0]]
if (
include_ref is not None
and git(repo, "rev-list", "--max-count=1", "--no-merges", include_ref, "--not", history[0]).strip()
):
unreleased_refs.insert(0, git(repo, "rev-parse", f"{include_ref}^{{commit}}").strip())
refs = tags.copy()
if not tags or history[0] != tags[0][1]:
refs.insert(0, ("Unreleased", history[0]))
if len(unreleased_refs) > 1 or not tags or history[0] != tags[0][1]:
refs.insert(0, ("Unreleased", unreleased_refs[0]))

return [
ChangeSet(
tag=name,
full_sha=sha,
date=git(repo, "show", "-s", "--format=%cI", sha).strip(),
# The oldest range has no lower bound, so it includes the root commit.
commits=commits_between(repo, sha, [older_sha for _, older_sha in refs[index + 1 :]]),
commits=commits_between(
repo,
unreleased_refs if name == "Unreleased" else [sha],
[older_sha for _, older_sha in refs[index + 1 :]],
),
)
for index, (name, sha) in enumerate(refs)
]
Expand All @@ -73,9 +91,13 @@ def generate_changelog(repo: Path) -> list[ChangeSet]:
def main() -> None:
parser = argparse.ArgumentParser(description="Generate commit history from reachable Git version tags.")
parser.add_argument("--repo", type=Path, default=Path())
parser.add_argument(
"--ref", default="HEAD", help="Reference whose reachable version tags define the release groups."
)
parser.add_argument("--include-ref", help="Include additional unreleased commits from this reference.")
parser.add_argument("--output", type=Path, default=Path("ui/exported/CHANGELOG.json"))
args = parser.parse_args()
changes = generate_changelog(args.repo)
changes = generate_changelog(args.repo, ref=args.ref, include_ref=args.include_ref)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(changes, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

Expand Down
23 changes: 5 additions & 18 deletions .github/workflows/build-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,6 @@ jobs:
- name: Install Python dependencies
run: uv sync --frozen --all-packages --dev

- name: Check Python
run: .githooks/pre-commit --all

- name: Set up Node.js
uses: actions/setup-node@v7
with:
Expand All @@ -90,21 +87,11 @@ jobs:
working-directory: ui
run: bun install --frozen-lockfile

- name: Check frontend formatting
working-directory: ui
run: bun run format:check

- name: Lint frontend
working-directory: ui
run: bun run lint:ci

- name: Check frontend types
working-directory: ui
run: bun run lint:tsc

- name: Test frontend
working-directory: ui
run: bun test
- name: Pre-commit checks
env:
# The full suite can exceed five minutes on hosted runners.
PYTEST_ADDOPTS: "--session-timeout=900"
run: .githooks/pre-commit --all

- name: Build frontend
working-directory: ui
Expand Down
53 changes: 53 additions & 0 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Publish commit history

on:
workflow_dispatch:
push:
branches: [master, dev]
tags: ["v*"]

permissions:
contents: read

concurrency:
group: changelog-pages
cancel-in-progress: false

jobs:
publish:
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.14"

- name: Generate commit history
run: >-
python .github/scripts/generate_changelog.py
--ref origin/master
--include-ref origin/dev
--output "${{ runner.temp }}/changelog/CHANGELOG.json"

- name: Upload commit history
uses: actions/upload-pages-artifact@v5
with:
path: ${{ runner.temp }}/changelog

- name: Publish commit history
id: deployment
uses: actions/deploy-pages@v5
23 changes: 5 additions & 18 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ jobs:
- name: Install Python dependencies
run: uv sync --frozen --all-packages --dev

- name: Check Python
run: .githooks/pre-commit --all

- name: Set up Node.js
uses: actions/setup-node@v7
with:
Expand All @@ -101,21 +98,11 @@ jobs:
working-directory: ui
run: bun install --frozen-lockfile

- name: Check frontend formatting
working-directory: ui
run: bun run format:check

- name: Lint frontend
working-directory: ui
run: bun run lint:ci

- name: Check frontend types
working-directory: ui
run: bun run lint:tsc

- name: Test frontend
working-directory: ui
run: bun test
- name: Pre-commit checks
env:
# The full suite can exceed five minutes on hosted runners.
PYTEST_ADDOPTS: "--session-timeout=900"
run: .githooks/pre-commit --all

- name: Build frontend
working-directory: ui
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ individual files or let Millwright handle new files automatically.
> [!WARNING]
> Millwright is still in alpha phase and may have bugs or incomplete features.

### If you like my work, you might also like my other projects:

* [YTPTube](https://github.com/arabcoders/ytptube): Media download manager, automation interface, and media library preparation layer for yt-dlp.
* [WatchState](https://github.com/arabcoders/watchstate): Self-hosted service to sync your media servers without relying on 3rd-party external services.

## Screenshots

Dashboard:

![Dashboard](ui/public/images/screenshots/dashbaord.jpg)

Statistics:

![Statistics](ui/public/images/screenshots/stats.jpg)

Job details:

![Job details and flow decisions](ui/public/images/screenshots/job-view.jpg)

## How it works

1. Add your media folders as a **Library**.
Expand Down
51 changes: 48 additions & 3 deletions tests/test_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,27 @@ def commit(repo: Path, message: str, date: str = "2026-01-01T12:00:00+00:00") ->
return git(repo, "rev-parse", "HEAD")


def generate(repo: Path) -> list[dict]:
def generate(repo: Path, *args: str) -> list[dict]:
output = repo / "exported/CHANGELOG.json"
subprocess.run([sys.executable, str(SCRIPT), "--repo", str(repo), "--output", str(output)], check=True)
subprocess.run([sys.executable, str(SCRIPT), "--repo", str(repo), "--output", str(output), *args], check=True)
return json.loads(output.read_text())


def test_untagged_root(repo: Path) -> None:
root = commit(repo, "Initial media support\n\n- Keep this list\n- Keep indentation\n\n```\n example\n```")
head = commit(repo, "Add libraries", "2026-01-02T12:00:00+00:00")
head = commit(
repo,
"Add libraries\n\nCo-authored-by: Another Author <author@example.test>\nCO-AUTHORED-BY: Other <other@example.test>",
"2026-01-02T12:00:00+00:00",
)

groups = generate(repo)

assert [group["tag"] for group in groups] == ["Unreleased"]
assert groups[0]["full_sha"] == head
assert datetime.fromisoformat(groups[0]["date"]) == datetime(2026, 1, 2, 12, tzinfo=UTC)
assert [item["full_sha"] for item in groups[0]["commits"]] == [head, root]
assert groups[0]["commits"][0]["message"] == "Add libraries"
initial = groups[0]["commits"][-1]
assert initial["message"] == git(repo, "show", "-s", "--format=%B", root)
assert initial["author"] == "History Author"
Expand All @@ -70,6 +75,8 @@ def test_tagged_ranges(repo: Path) -> None:
groups = generate(repo)
assert [group["tag"] for group in groups] == ["v0.1.0-alpha.2", "v0.1.0-alpha.1"]
assert [[item["full_sha"] for item in group["commits"]] for group in groups] == [[second], [root]]
git(repo, "branch", "dev")
assert generate(repo, "--include-ref", "dev") == groups

head = commit(repo, "Add playback")
groups = generate(repo)
Expand All @@ -91,3 +98,41 @@ def test_reachable_versions(repo: Path) -> None:

assert [group["tag"] for group in groups] == ["Unreleased", "v0.1.0"]
assert [[item["full_sha"] for item in group["commits"]] for group in groups] == [[head], [root]]


def test_development_after_release(repo: Path) -> None:
root = commit(repo, "Start conversion")
git(repo, "tag", "v0.1.0")
git(repo, "checkout", "-b", "dev")
development = commit(repo, "Development branch change")

groups = generate(repo, "--ref", "main", "--include-ref", "dev")

assert [group["tag"] for group in groups] == ["Unreleased", "v0.1.0"]
assert [[item["full_sha"] for item in group["commits"]] for group in groups] == [[development], [root]]


@pytest.mark.parametrize("tagged", [False, True])
def test_combined_branches(repo: Path, tagged: bool) -> None:
root = commit(repo, "Start conversion")
if tagged:
git(repo, "tag", "v0.1.0")
shared = commit(repo, "Shared development")
git(repo, "branch", "dev")
primary = commit(repo, "Primary branch change")
git(repo, "update-ref", "refs/remotes/origin/master", primary)
git(repo, "checkout", "dev")
development = commit(repo, "Development branch change")
git(repo, "tag", "v9.0.0")
git(repo, "update-ref", "refs/remotes/origin/dev", development)

groups = generate(repo, "--ref", "origin/master", "--include-ref", "origin/dev")

assert [group["tag"] for group in groups] == (["Unreleased", "v0.1.0"] if tagged else ["Unreleased"])
assert groups[0]["full_sha"] == development
commits = [item["full_sha"] for item in groups[0]["commits"]]
expected = {development, primary, shared} if tagged else {development, primary, shared, root}
assert set(commits) == expected
assert len(commits) == len(expected)
if tagged:
assert [item["full_sha"] for item in groups[1]["commits"]] == [root]
Loading
Loading