project_query.py 3.46 KB
"""c_project_list / c_project_get / c_project_update (2026-08-03, v_project_list):
02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an MVP
non-goal -- this is a deliberate, explicit scope expansion beyond that,
requested directly by the user rather than found while implementing
something else. Same read/update-query pattern as task_query.py.

"Delete" maps to `project.status = 'archived'`, never a physical row
delete -- 01-M's own status enum (`active`/`archived`) already models this
as a soft-delete concept, and `project_id` is referenced via FK from
task/artifact/bug_record/work_graph_run/etc.; a literal DELETE would either
violate those constraints or need a destructive cascade that throws away
real audit history this codebase otherwise goes out of its way to
preserve (Git-tracked artifacts, bug records, ...). `c_project_create`
(project_scaffold.py) already handles "point at a folder that already has
real code in it" correctly -- it only ever creates `.graphlemon/` inside
the target folder and never touches anything else there -- so importing an
existing codebase needs no changes here, just this module's read/update
side.
"""

_VALID_STATUSES = ("active", "archived")


class ProjectNotFoundError(Exception):
    """NOT_FOUND."""


class InvalidParamError(Exception):
    """INVALID_PARAM."""


def list_projects(conn):
    rows = conn.execute(
        "SELECT project_id, name, target_folder_path, has_git_repo, status, created_at "
        "FROM project ORDER BY created_at DESC"
    ).fetchall()
    projects = []
    for r in rows:
        goal_count = conn.execute("SELECT COUNT(*) FROM task WHERE project_id = ?", (r[0],)).fetchone()[0]
        projects.append({
            "project_id": r[0], "name": r[1], "target_folder_path": r[2],
            "has_git_repo": bool(r[3]), "status": r[4], "created_at": r[5], "goal_count": goal_count,
        })
    return projects


def get_project(conn, project_id):
    row = conn.execute(
        "SELECT project_id, name, target_folder_path, has_git_repo, graphlemon_subdir_initialized, "
        "status, created_at FROM project WHERE project_id = ?",
        (project_id,),
    ).fetchone()
    if row is None:
        raise ProjectNotFoundError(project_id)
    goal_count = conn.execute("SELECT COUNT(*) FROM task WHERE project_id = ?", (project_id,)).fetchone()[0]
    return {
        "project_id": row[0], "name": row[1], "target_folder_path": row[2],
        "has_git_repo": bool(row[3]), "graphlemon_subdir_initialized": bool(row[4]),
        "status": row[5], "created_at": row[6], "goal_count": goal_count,
    }


def update_project(conn, project_id, name=None, status=None):
    """Returns the updated project (same shape as get_project). Either
    field may be omitted; a call with neither is a harmless no-op read."""
    if conn.execute("SELECT 1 FROM project WHERE project_id = ?", (project_id,)).fetchone() is None:
        raise ProjectNotFoundError(project_id)
    if status is not None and status not in _VALID_STATUSES:
        raise InvalidParamError("status must be one of {}, got {!r}".format(_VALID_STATUSES, status))
    if name is not None:
        if not name.strip():
            raise InvalidParamError("name cannot be blank")
        conn.execute("UPDATE project SET name = ? WHERE project_id = ?", (name, project_id))
    if status is not None:
        conn.execute("UPDATE project SET status = ? WHERE project_id = ?", (status, project_id))
    conn.commit()
    return get_project(conn, project_id)