Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837489e4f2 | ||
|
|
9a0d5f3109 | ||
|
|
5a930319ba | ||
|
|
266af7870d | ||
|
|
f446573c3d | ||
|
|
82d6812c7f | ||
|
|
e3d7007417 | ||
|
|
0e980ee4b0 | ||
|
|
b5870d4694 | ||
|
|
c810d63bee | ||
|
|
a3a056d6fd | ||
|
|
2414437061 | ||
|
|
76dc75a03b | ||
|
|
c8765959ea | ||
|
|
f85b92a885 | ||
|
|
b81c4aa600 | ||
|
|
404698521f | ||
|
|
88b351a96e |
+28
-21
@@ -1,12 +1,19 @@
|
||||
# CI runs first; build only proceeds if all checks pass.
|
||||
#
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
|
||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||
# Push to main: typecheck + lint + test + build :<sha> (no moving tag)
|
||||
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
||||
#
|
||||
# main pushes are NOT gated here: a merge to main only happens after
|
||||
# dev has already passed CI, and the release tag is the sole trigger
|
||||
# for a production image. Re-running CI on the merge commit just burns
|
||||
# runner time without changing the outcome.
|
||||
# Both dev and main are gated AND built. dev pushes move the :dev tag; main
|
||||
# pushes publish only the immutable :<sha> image — no :main tag, because
|
||||
# :latest (release-only) is the single production pointer and a :main alias
|
||||
# would just duplicate it. Running CI on the main merge commit is intentional:
|
||||
# main is validated and its :<sha> image is the rollback point. The v* release
|
||||
# tag is the ONLY trigger that publishes :latest plus the immutable :<version>.
|
||||
#
|
||||
# Successive pushes to the SAME ref supersede each other (see concurrency
|
||||
# below), so rapid pushes don't stack identical work; dev and main runs are
|
||||
# independent refs and never cancel one another.
|
||||
#
|
||||
# To cut a release:
|
||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||
@@ -16,11 +23,8 @@
|
||||
# gating on branch push is already enough.
|
||||
#
|
||||
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
||||
# honor `on.push.branches` as a filter — merge commits landing on main
|
||||
# still trigger the workflow, producing redundant runs on the same SHA
|
||||
# that was already gated on dev. Every job therefore repeats the ref
|
||||
# check so main pushes trigger the workflow but every job skips
|
||||
# immediately (no runner time, no duplicate work).
|
||||
# honor `on.push.branches` as a filter, so every job repeats the ref check
|
||||
# explicitly — permitting dev, main, and v* tags, rejecting anything else.
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# REGISTRY_USER — your Forgejo username
|
||||
@@ -29,7 +33,7 @@ name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, main]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "src/**"
|
||||
@@ -67,8 +71,8 @@ env:
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
# Skip on main merge-commit pushes — see workflow header comment.
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
# Gate dev, main, and v* tags; reject any other ref (see header note).
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -92,7 +96,7 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -106,7 +110,7 @@ jobs:
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -138,11 +142,9 @@ jobs:
|
||||
build:
|
||||
name: Build & push image
|
||||
needs: [typecheck, lint, test]
|
||||
# Build on dev branch pushes and version tag pushes only.
|
||||
# Mirrors the ref guard on the gate jobs above — main merge-commit
|
||||
# pushes skip here too, so no production image is ever built from a
|
||||
# raw main push (only from the v* tag the release creates).
|
||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
||||
# Build on dev, main, and v* tag pushes. dev → :dev, main → (sha only),
|
||||
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
@@ -168,6 +170,11 @@ jobs:
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
;;
|
||||
refs/heads/main)
|
||||
# main publishes only the immutable :<sha> image (set above) —
|
||||
# no :main tag; :latest (release-only) is the production pointer.
|
||||
BUILD_VERSION="main"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
|
||||
@@ -36,7 +36,8 @@ Mechanics:
|
||||
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
||||
unchanged on updates.
|
||||
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
||||
"not set".
|
||||
"not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
|
||||
removes the task from its milestone); 0 leaves it unchanged.
|
||||
|
||||
Keep task state honest — this is what makes the project a trustworthy record:
|
||||
- When you begin working a task, set it to in_progress (update_task
|
||||
@@ -88,6 +89,17 @@ summary, open tasks, and recent notes — everything you need to know the lay of
|
||||
the land before mutating. Don't call get_project + get_applicable_rules + a
|
||||
search separately when enter_project already composes them.
|
||||
|
||||
Don't wait to be told which project you're in. At the start of a session that
|
||||
touches Scribe — or the moment work clearly belongs to a project but none is in
|
||||
scope — bootstrap project context proactively: search for a related existing
|
||||
project (search / list_projects, matching on the work's subject, the repo or
|
||||
directory name, and recent activity). If you find a confident match, propose it
|
||||
and call enter_project once the operator confirms. If nothing matches, offer to
|
||||
create a project, confirming its name and goal first. Always confirm before
|
||||
adopting or creating — never do either silently, and never guess a project into
|
||||
existence. Once a project is in scope, the enter_project handshake and the
|
||||
host-memory pointer step above both apply.
|
||||
|
||||
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
|
||||
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
||||
before any brainstorming, design, or plan-writing skill runs. start_planning
|
||||
|
||||
@@ -14,7 +14,7 @@ Sentinels (preserved from existing fable-mcp):
|
||||
what makes a Note a Task)
|
||||
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
|
||||
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
|
||||
"leave unchanged" on update
|
||||
"leave unchanged" on update; on update, -1 clears the FK (sets it NULL)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -147,8 +147,10 @@ async def update_task(
|
||||
the lifecycle: set in_progress when you start, done when complete —
|
||||
don't leave finished work at todo.
|
||||
priority: New priority — one of: none, low, medium, high.
|
||||
project_id: New project. Omit (0) to leave unchanged.
|
||||
milestone_id: New milestone. Omit (0) to leave unchanged.
|
||||
project_id: New project. 0 = leave unchanged, -1 = clear (remove from
|
||||
its project; also clears the milestone), positive = set.
|
||||
milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove
|
||||
from its milestone), positive = set.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
@@ -160,9 +162,15 @@ async def update_task(
|
||||
fields["status"] = status
|
||||
if priority:
|
||||
fields["priority"] = priority
|
||||
if project_id:
|
||||
# Optional FKs: 0 = leave unchanged, -1 = clear (set NULL), positive = set.
|
||||
if project_id == -1:
|
||||
fields["project_id"] = None
|
||||
fields["milestone_id"] = None # a milestone can't outlive its project
|
||||
elif project_id:
|
||||
fields["project_id"] = project_id
|
||||
if milestone_id:
|
||||
if milestone_id == -1:
|
||||
fields["milestone_id"] = None
|
||||
elif milestone_id:
|
||||
fields["milestone_id"] = milestone_id
|
||||
note = await notes_svc.update_note(uid, task_id, **fields)
|
||||
if note is None:
|
||||
|
||||
@@ -164,6 +164,46 @@ async def test_update_task_raises_when_not_found():
|
||||
await update_task(task_id=999, status="done")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_milestone_zero_is_omitted():
|
||||
"""milestone_id=0 is 'leave unchanged' — must not reach the service."""
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||
await update_task(task_id=1, milestone_id=0)
|
||||
assert "milestone_id" not in mock.call_args.kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_milestone_positive_is_set():
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||
await update_task(task_id=1, milestone_id=42)
|
||||
assert mock.call_args.kwargs["milestone_id"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_milestone_negative_one_clears():
|
||||
"""milestone_id=-1 clears the milestone (sets the column NULL)."""
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||
await update_task(task_id=1, milestone_id=-1)
|
||||
assert mock.call_args.kwargs["milestone_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_clearing_project_also_clears_milestone():
|
||||
"""project_id=-1 clears the project and, with it, the milestone."""
|
||||
fake = _fake_task()
|
||||
mock = AsyncMock(return_value=fake)
|
||||
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
|
||||
await update_task(task_id=1, project_id=-1)
|
||||
assert mock.call_args.kwargs["project_id"] is None
|
||||
assert mock.call_args.kwargs["milestone_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_task_log_returns_log_dict():
|
||||
log = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user