Merge branch 'master' into perf/activity-events-eventtype-createdat
This commit is contained in:
@@ -1054,12 +1054,16 @@ async def update_task(
|
||||
updates.pop("comment", None)
|
||||
updates.pop("depends_on_task_ids", None)
|
||||
updates.pop("tag_ids", None)
|
||||
requested_status = payload.status if "status" in payload.model_fields_set else None
|
||||
update = _TaskUpdateInput(
|
||||
task=task,
|
||||
actor=actor,
|
||||
board_id=board_id,
|
||||
previous_status=previous_status,
|
||||
previous_assigned=previous_assigned,
|
||||
status_requested=(
|
||||
requested_status is not None and requested_status != previous_status
|
||||
),
|
||||
updates=updates,
|
||||
comment=comment,
|
||||
depends_on_task_ids=depends_on_task_ids,
|
||||
@@ -1299,6 +1303,7 @@ class _TaskUpdateInput:
|
||||
board_id: UUID
|
||||
previous_status: str
|
||||
previous_assigned: UUID | None
|
||||
status_requested: bool
|
||||
updates: dict[str, object]
|
||||
comment: str | None
|
||||
depends_on_task_ids: list[UUID] | None
|
||||
@@ -1597,7 +1602,7 @@ async def _apply_lead_task_update(
|
||||
task_id=update.task.id,
|
||||
previous_status=update.previous_status,
|
||||
target_status=update.task.status,
|
||||
status_requested="status" in update.updates,
|
||||
status_requested=update.status_requested,
|
||||
)
|
||||
await _require_review_before_done_when_enabled(
|
||||
session,
|
||||
@@ -1878,7 +1883,7 @@ async def _finalize_updated_task(
|
||||
task_id=update.task.id,
|
||||
previous_status=update.previous_status,
|
||||
target_status=update.task.status,
|
||||
status_requested="status" in update.updates,
|
||||
status_requested=update.status_requested,
|
||||
)
|
||||
await _require_review_before_done_when_enabled(
|
||||
session,
|
||||
|
||||
@@ -22,7 +22,7 @@ def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('agents', sa.Column('gateway_id', sa.Uuid(), nullable=False))
|
||||
op.create_index(op.f('ix_agents_gateway_id'), 'agents', ['gateway_id'], unique=False)
|
||||
op.create_foreign_key(None, 'agents', 'gateways', ['gateway_id'], ['id'])
|
||||
op.create_foreign_key('fk_agents_gateway_id_gateways', 'agents', 'gateways', ['gateway_id'], ['id'])
|
||||
op.drop_column('gateways', 'main_session_key')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -30,7 +30,7 @@ def upgrade() -> None:
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('gateways', sa.Column('main_session_key', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||
op.drop_constraint(None, 'agents', type_='foreignkey')
|
||||
op.drop_constraint('fk_agents_gateway_id_gateways', 'agents', type_='foreignkey')
|
||||
op.drop_index(op.f('ix_agents_gateway_id'), table_name='agents')
|
||||
op.drop_column('agents', 'gateway_id')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
85
backend/scripts/check_migration_graph.py
Normal file
85
backend/scripts/check_migration_graph.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Migration graph integrity checks for CI.
|
||||
|
||||
Checks:
|
||||
- alembic script graph can be loaded (detects broken/missing links)
|
||||
- single head by default (unless ALLOW_MULTIPLE_HEADS=true)
|
||||
- no orphan revisions (all revisions reachable from heads)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
|
||||
def _truthy(value: str | None) -> bool:
|
||||
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
alembic_ini = root / "alembic.ini"
|
||||
cfg = Config(str(alembic_ini))
|
||||
cfg.attributes["configure_logger"] = False
|
||||
|
||||
try:
|
||||
script = ScriptDirectory.from_config(cfg)
|
||||
except Exception as exc: # pragma: no cover - CI path
|
||||
print(f"ERROR: unable to load Alembic script directory: {exc}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
heads = list(script.get_heads())
|
||||
except Exception as exc: # pragma: no cover - CI path
|
||||
print(f"ERROR: unable to resolve Alembic heads: {exc}")
|
||||
return 1
|
||||
|
||||
allow_multiple_heads = _truthy(os.getenv("ALLOW_MULTIPLE_HEADS"))
|
||||
if not heads:
|
||||
print("ERROR: no Alembic heads found")
|
||||
return 1
|
||||
|
||||
if len(heads) > 1 and not allow_multiple_heads:
|
||||
print("ERROR: multiple Alembic heads detected (set ALLOW_MULTIPLE_HEADS=true only for intentional merge windows)")
|
||||
for h in heads:
|
||||
print(f" - {h}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
reachable: set[str] = set()
|
||||
for walk_rev in script.walk_revisions(base="base", head="heads"):
|
||||
if walk_rev is None:
|
||||
continue
|
||||
if walk_rev.revision:
|
||||
reachable.add(walk_rev.revision)
|
||||
except Exception as exc: # pragma: no cover - CI path
|
||||
print(f"ERROR: failed while walking Alembic revision graph: {exc}")
|
||||
return 1
|
||||
|
||||
all_revisions: set[str] = set()
|
||||
# Alembic's revision_map is dynamically typed; guard None values.
|
||||
for map_rev in script.revision_map._revision_map.values():
|
||||
if map_rev is None:
|
||||
continue
|
||||
revision = getattr(map_rev, "revision", None)
|
||||
if revision:
|
||||
all_revisions.add(revision)
|
||||
|
||||
orphans = sorted(all_revisions - reachable)
|
||||
if orphans:
|
||||
print("ERROR: orphan Alembic revisions detected (not reachable from heads):")
|
||||
for orphan_rev in orphans:
|
||||
print(f" - {orphan_rev}")
|
||||
return 1
|
||||
|
||||
print("OK: migration graph integrity passed")
|
||||
print(f"Heads: {', '.join(heads)}")
|
||||
print(f"Reachable revisions: {len(reachable)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -356,6 +356,53 @@ async def test_update_task_allows_status_change_with_pending_approval_when_toggl
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_allows_dependency_change_with_pending_approval() -> None:
|
||||
engine = await _make_engine()
|
||||
try:
|
||||
async with await _make_session(engine) as session:
|
||||
board, task, _agent = await _seed_board_task_and_agent(
|
||||
session,
|
||||
task_status="review",
|
||||
require_approval_for_done=False,
|
||||
block_status_changes_with_pending_approval=True,
|
||||
)
|
||||
dependency = Task(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
title="Dependency",
|
||||
status="inbox",
|
||||
)
|
||||
session.add(dependency)
|
||||
session.add(
|
||||
Approval(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
task_id=task.id,
|
||||
action_type="task.execute",
|
||||
confidence=70,
|
||||
status="pending",
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
updated = await tasks_api.update_task(
|
||||
payload=TaskUpdate(
|
||||
status="review",
|
||||
depends_on_task_ids=[dependency.id],
|
||||
),
|
||||
task=task,
|
||||
session=session,
|
||||
actor=ActorContext(actor_type="user"),
|
||||
)
|
||||
|
||||
assert updated.depends_on_task_ids == [dependency.id]
|
||||
assert updated.status == "inbox"
|
||||
assert updated.blocked_by_task_ids == [dependency.id]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> (
|
||||
None
|
||||
|
||||
Reference in New Issue
Block a user