From 10925680540cfc535e2d3a49a27edb72545f4d88 Mon Sep 17 00:00:00 2001 From: Guillaume Flambard <56681566+guillaume-flambard@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:22:24 +0200 Subject: [PATCH 1/8] feat(projects): safe issue sync prototype --- scripts/projects_sync.py | 183 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 scripts/projects_sync.py diff --git a/scripts/projects_sync.py b/scripts/projects_sync.py new file mode 100644 index 0000000..7b4a64a --- /dev/null +++ b/scripts/projects_sync.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Idempotently attach existing open Issues to a private GitHub Project V2.""" +import argparse +import json +import re +import subprocess +import sys + +class Blocked(RuntimeError): + pass + +def gh(*args, runner=subprocess.run): + try: + p = runner(["gh", "api", *args], capture_output=True, text=True, timeout=45, check=False) + except (OSError, subprocess.TimeoutExpired): + raise Blocked("GitHub CLI unavailable") from None + if p.returncode: + raise Blocked("GitHub API denied request (access or rate limit)") + try: + data = json.loads(p.stdout) + except ValueError: + raise Blocked("GitHub returned invalid JSON") from None + if isinstance(data, dict) and data.get("errors"): + raise Blocked("GitHub GraphQL error: " + ",".join( + sorted({e.get("type", "unknown") for e in data["errors"]}))) + return data + +def pages(path, api=gh): + rows = [] + for page in range(1, 101): + current = api("-X", "GET", path + ("&" if "?" in path else "?") + + "per_page=100&page=" + str(page)) + if not isinstance(current, list): + raise Blocked("GitHub pagination returned an unexpected response") + rows.extend(current) + if len(current) < 100: + return rows + raise Blocked("Incomplete GitHub pagination") + +def repositories(org=None, names=None, api=gh): + if org: + if not re.fullmatch(r"[A-Za-z0-9-]+", org): + raise Blocked("Invalid organization") + src = [r for r in pages("orgs/" + org + "/repos?type=all", api) + if r.get("owner", {}).get("login", "").lower() == org.lower()] + else: + if not names: + raise Blocked("No repository selected") + src = [] + for name in sorted(set(names)): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", name) or ".." in name: + raise Blocked("Invalid repository selector") + src.append(api("-X", "GET", "repos/" + name)) + active = [] + for repo in src: + if repo.get("archived") or repo.get("disabled"): + continue + if not isinstance(repo.get("private"), bool) or not repo.get("full_name"): + raise Blocked("Repository identity/visibility unavailable") + active.append(repo["full_name"]) + return sorted(set(active)) + +def issues_for(repos, api=gh): + items = {} + for repo in repos: + for issue in pages("repos/" + repo + "/issues?state=open", api): + if issue.get("state") != "open" or "pull_request" in issue: + continue + node = issue.get("node_id") + if not isinstance(node, str) or not node: + raise Blocked("Issue without GitHub node ID") + items[node] = (repo, issue.get("number")) + return items + +PROJECT_QUERY = """ +query($project:ID!, $after:String) { + node(id:$project) { + ... on ProjectV2 { + id title public closed + owner { ... on Organization {login} ... on User {login} } + items(first:100,after:$after) { + nodes {content {... on Issue {id}}} + pageInfo {hasNextPage endCursor} + } + } + } +} +""" +ADD_QUERY = """ +mutation($project:ID!, $issue:ID!) { + addProjectV2ItemById(input:{projectId:$project,contentId:$issue}) { + item {id} + } +} +""" + +def gql(query, variables, api=gh): + params = ["graphql", "-f", "query=" + query] + for k,v in variables.items(): + if v is not None: + params.extend(["-f", k + "=" + str(v)]) + result = api(*params) + if not isinstance(result.get("data"), dict): + raise Blocked("GraphQL returned no data") + return result["data"] + +def snapshot(project_id, owner, api=gh): + existing, cursor = set(), None + for _ in range(100): + node = gql(PROJECT_QUERY, {"project":project_id, "after":cursor}, api).get("node") + if not isinstance(node, dict) or node.get("id") != project_id: + raise Blocked("Project is inaccessible") + if node.get("public") is not False or node.get("closed") is not False: + raise Blocked("Project must be private and open") + if (node.get("owner") or {}).get("login", "").lower() != owner.lower(): + raise Blocked("Project owner mismatch") + listing = node.get("items") + if not isinstance(listing, dict): + raise Blocked("Project items are inaccessible") + existing.update(item["content"]["id"] for item in (listing.get("nodes") or []) + if item and item.get("content") and item["content"].get("id")) + page = listing.get("pageInfo") or {} + if not page.get("hasNextPage"): + return node.get("title"), existing + cursor = page.get("endCursor") + if not cursor: + raise Blocked("Project pagination is incomplete") + raise Blocked("Project items exceed safety bound") + +def pending(found, existing): + return [(node, repo, number) + for node,(repo,number) in sorted(found.items(), + key=lambda item:(item[1][0].lower(),item[1][1])) + if node not in existing] + +def sync(project_id, owner, repos, apply=False, max_add=100, api=gh): + if not repos: + raise Blocked("No active repositories") + title, existing = snapshot(project_id, owner, api) + issues = issues_for(repos, api) + missing = pending(issues, existing) + added = 0 + if apply: + for node,_,_ in missing[:max_add]: + data = gql(ADD_QUERY, {"project":project_id, "issue":node}, api) + if not ((data.get("addProjectV2ItemById") or {}).get("item") or {}).get("id"): + raise Blocked("Project write did not confirm the new item") + added += 1 + return {"project":title, "repositories":len(repos), "open_issues":len(issues), + "linked":len(issues)-len(missing), "missing_before_run":len(missing), + "added":added, "remaining":len(missing)-added, "applied":apply} + +def main(argv=None): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--project-id", required=True) + s = p.add_mutually_exclusive_group(required=True) + s.add_argument("--org") + s.add_argument("--repo", action="append") + p.add_argument("--apply", action="store_true") + p.add_argument("--max-add", type=int, default=100) + a = p.parse_args(argv) + try: + if not re.fullmatch(r"PVT_[A-Za-z0-9_-]+", a.project_id): + raise Blocked("Invalid project ID") + if not 1 <= a.max_add <= 500: + raise Blocked("max-add must be within 1..500") + if a.repo: + owners = {x.split("/")[0].lower() for x in a.repo} + if len(owners) != 1: + raise Blocked("Personal Project must have one repository owner") + owner = owners.pop() + else: + owner = a.org + receipt = sync(a.project_id, owner, repositories(a.org, a.repo), + a.apply, a.max_add) + print(json.dumps(receipt, sort_keys=True)) + return 0 + except Blocked as exc: + print("PROJECT_SYNC_BLOCKED: " + str(exc), file=sys.stderr) + return 2 + +if __name__ == "__main__": + raise SystemExit(main()) From 0ce3572addd0340ed9cdaecc1cd376effcfab598 Mon Sep 17 00:00:00 2001 From: Guillaume Flambard <56681566+guillaume-flambard@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:22:26 +0200 Subject: [PATCH 2/8] feat(projects): safe issue sync prototype --- tests/test_projects_sync.py | 65 +++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/test_projects_sync.py diff --git a/tests/test_projects_sync.py b/tests/test_projects_sync.py new file mode 100644 index 0000000..73f9789 --- /dev/null +++ b/tests/test_projects_sync.py @@ -0,0 +1,65 @@ +import importlib.util +from pathlib import Path +import subprocess +import unittest +from unittest.mock import Mock +path = Path(__file__).resolve().parents[1]/"scripts"/"projects_sync.py" +spec = importlib.util.spec_from_file_location("projects_sync",path) +app = importlib.util.module_from_spec(spec) +spec.loader.exec_module(app) + +class TestProjectSync(unittest.TestCase): + def test_idempotent_plan(self): + found={"A":("org/z",3),"B":("org/a",2),"C":("org/a",1)} + self.assertEqual(app.pending(found,{"B"}),[("C","org/a",1),("A","org/z",3)]) + self.assertEqual(app.pending(found,set(found)),[]) + + def test_reject_public_project(self): + def api(*args): + return {"data":{"node":{"id":"PVT_demo","title":"board","public":True, + "closed":False,"owner":{"login":"org"}, + "items":{"nodes":[],"pageInfo":{"hasNextPage":False}}}}} + with self.assertRaisesRegex(app.Blocked,"private"): + app.snapshot("PVT_demo","org",api) + + def test_reject_foreign_project(self): + def api(*args): + return {"data":{"node":{"id":"PVT_demo","title":"board","public":False, + "closed":False,"owner":{"login":"other"}, + "items":{"nodes":[],"pageInfo":{"hasNextPage":False}}}}} + with self.assertRaisesRegex(app.Blocked,"owner"): + app.snapshot("PVT_demo","org",api) + + def test_graphql_error_fails_closed(self): + proc=Mock(return_value=subprocess.CompletedProcess([],0, + '{"errors":[{"type":"RATE_LIMIT"}]}',"")) + with self.assertRaisesRegex(app.Blocked,"RATE_LIMIT"): + app.gh("graphql",runner=proc) + + def test_filter_prs_and_closed_issues(self): + rows=[{"node_id":"I1","number":1,"state":"open"}, + {"node_id":"PR2","number":2,"state":"open","pull_request":{}}, + {"node_id":"I3","number":3,"state":"closed"}] + self.assertEqual(app.issues_for(["org/demo"],lambda *args:rows), + {"I1":("org/demo",1)}) + + def test_dry_run_never_writes(self): + calls=[] + def api(*args): + calls.append(args) + if args[0]=="graphql": + return {"data":{"node":{"id":"PVT_demo","title":"board","public":False, + "closed":False,"owner":{"login":"org"}, + "items":{"nodes":[],"pageInfo":{"hasNextPage":False}}}}} + return [{"node_id":"I1","number":1,"state":"open"}] + receipt=app.sync("PVT_demo","org",["org/demo"],api=api) + self.assertEqual((receipt["added"],receipt["remaining"]),(0,1)) + self.assertEqual(len([args for args in calls if args[0]=="graphql"]),1) + + def test_ignore_archived_repository(self): + rows=[{"owner":{"login":"org"},"full_name":"org/demo","private":True}, + {"owner":{"login":"org"},"full_name":"org/old","private":True,"archived":True}] + self.assertEqual(app.repositories(org="org",api=lambda *args:rows),["org/demo"]) + +if __name__ == "__main__": + unittest.main() From 2b7d0e4c69e6087fcf112f9c57780a8633b28027 Mon Sep 17 00:00:00 2001 From: Guillaume Flambard <56681566+guillaume-flambard@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:22:27 +0200 Subject: [PATCH 3/8] feat(projects): safe issue sync prototype --- docs/projects-v2-sync.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/projects-v2-sync.md diff --git a/docs/projects-v2-sync.md b/docs/projects-v2-sync.md new file mode 100644 index 0000000..3eedadd --- /dev/null +++ b/docs/projects-v2-sync.md @@ -0,0 +1,35 @@ +# GitHub Projects V2 sync — Memo Labs + +Source of truth: existing GitHub Issues. GitHub Projects provides portfolio +views only; technical specs and ADRs stay versioned with repository code. + +Org board: https://github.com/orgs/memolabs-dev/projects/2 +Private organization project node ID: PVT_kwDOFFp4LM4BnzMe +Baseline on 2026-10-19: 99 open Issues were imported from 9 org repositories. +The 99 count is a verified snapshot, NOT a continuously synchronized guarantee. + +Personal board: https://github.com/users/guillaume-flambard/projects/2 +Baseline: 22 open Issues in lab-infra, kollio and foldready. +Personal Project must first have its actual PVT_ ID verified. + +DRY RUN (no mutations): + python3 scripts/projects_sync.py --org memolabs-dev --project-id PVT_kwDOFFp4LM4BnzMe + +Apply only after reviewing the dry-run count: + python3 scripts/projects_sync.py --org memolabs-dev --project-id PVT_kwDOFFp4LM4BnzMe --apply --max-add 100 + +Personal Project: repeat --repo for guillaume-flambard/lab-infra, +guillaume-flambard/kollio, and guillaume-flambard/foldready, then pass +the verified personal Project ID. Never mix owners. + +Requires an authenticated gh CLI with read access to each selected repo and +write access to the private Project. Repeatable: existing items are not added +twice, and existing item status/priority is never changed. No new Issues, +leases, agent dispatch, auto-merge or deployment are triggered. +Archived repositories and PRs are excluded. API errors fail closed. + +Important: no recurring schedule has been activated. Before automation, +configure an appropriately restricted token or GitHub App with Projects v2 +write access; Actions GITHUB_TOKEN alone cannot update the organization board. +Run a successful live dry run, an apply, and a second idempotence check. +Keep Memo Labs OS Project #1 as the separate agent Coordinator view. From 46f84ec8e5b31cb601a950e470eb209f6b903ea6 Mon Sep 17 00:00:00 2001 From: Guillaume Flambard <56681566+guillaume-flambard@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:25:07 +0200 Subject: [PATCH 4/8] fix(uni): include project sync in trusted sources --- .uni/config.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.uni/config.toml b/.uni/config.toml index e0c2a34..7e5dc67 100644 --- a/.uni/config.toml +++ b/.uni/config.toml @@ -28,6 +28,7 @@ files = [ "scripts/land.py", "scripts/post-activation.py", "scripts/private_owner_allowlist.py", + "scripts/projects_sync.py", "scripts/radar.py", "scripts/register-guardian.py", "scripts/steward.py", @@ -43,6 +44,7 @@ files = [ "tests/test_issue_queue.py", "tests/test_land.py", "tests/test_private_owner_allowlist.py", + "tests/test_projects_sync.py", "tests/test_uni_governance.py", "tests/test_uni_provenance_validation.py", "tests/test_uni_receipt_export.py", From e3c598553212789d3f3f06df9c78acd69332f479 Mon Sep 17 00:00:00 2001 From: Guillaume Flambard <56681566+guillaume-flambard@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:15:32 +0200 Subject: [PATCH 5/8] feat(projects): document and gate portfolio reconciliation --- .github/workflows/portfolio-projects-sync.yml | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/portfolio-projects-sync.yml diff --git a/.github/workflows/portfolio-projects-sync.yml b/.github/workflows/portfolio-projects-sync.yml new file mode 100644 index 0000000..612c97d --- /dev/null +++ b/.github/workflows/portfolio-projects-sync.yml @@ -0,0 +1,54 @@ +name: Memo Labs Engineering Project Sync + +on: + workflow_dispatch: + inputs: + apply: + description: 'Add missing Issues after a successful dry run' + type: boolean + required: true + default: false + schedule: + - cron: '17 2,8,14,20 * * *' + +permissions: + contents: read + +concurrency: + group: engineering-projects-sync + cancel-in-progress: false + +jobs: + organization: + if: >- + github.repository == 'memolabs-dev/.github' && + github.ref == 'refs/heads/main' && + (github.event_name == 'workflow_dispatch' || + vars.MEMOLABS_PROJECTS_SYNC_ENABLED == 'true') + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout trusted main + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Verify Projects credential is configured + env: + GH_TOKEN: ${{ secrets.MEMOLABS_PROJECTS_TOKEN }} + shell: bash + run: | + if [[ -z "$GH_TOKEN" ]]; then + echo '::error::Configure MEMOLABS_PROJECTS_TOKEN with scoped Projects write and repo read permissions.' + exit 1 + fi + - name: Reconcile read-only dry run + env: + GH_TOKEN: ${{ secrets.MEMOLABS_PROJECTS_TOKEN }} + shell: bash + run: python3 scripts/projects_sync.py --org memolabs-dev --project-id PVT_kwDOFFp4LM4BnzMe + - name: Apply missing Issue links idempotently + if: github.event_name == 'schedule' || inputs.apply + env: + GH_TOKEN: ${{ secrets.MEMOLABS_PROJECTS_TOKEN }} + shell: bash + run: python3 scripts/projects_sync.py --org memolabs-dev --project-id PVT_kwDOFFp4LM4BnzMe --apply --max-add 250 From 95fae1ef154f56ec57bea23244f9e2e2727a9cb7 Mon Sep 17 00:00:00 2001 From: Guillaume Flambard <56681566+guillaume-flambard@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:15:33 +0200 Subject: [PATCH 6/8] feat(projects): document and gate portfolio reconciliation --- .uni/config.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.uni/config.toml b/.uni/config.toml index 7e5dc67..0dfd2cd 100644 --- a/.uni/config.toml +++ b/.uni/config.toml @@ -8,6 +8,7 @@ files = [ ".github/workflows/app-repo-access-smoke.yml", ".github/workflows/app-write-smoke.yml", ".github/workflows/automation-spec-ci.yml", + ".github/workflows/portfolio-projects-sync.yml", ".github/workflows/guardian-check-smoke.yml", ".github/workflows/guardian-ci-provenance-smoke.yml", ".github/workflows/land.yml", From c51f980ba7856452e94b8d113f9b1bef27988b04 Mon Sep 17 00:00:00 2001 From: Guillaume Flambard <56681566+guillaume-flambard@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:15:35 +0200 Subject: [PATCH 7/8] feat(projects): document and gate portfolio reconciliation --- docs/projects-v2-sync.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/projects-v2-sync.md b/docs/projects-v2-sync.md index 3eedadd..58f7559 100644 --- a/docs/projects-v2-sync.md +++ b/docs/projects-v2-sync.md @@ -33,3 +33,14 @@ configure an appropriately restricted token or GitHub App with Projects v2 write access; Actions GITHUB_TOKEN alone cannot update the organization board. Run a successful live dry run, an apply, and a second idempotence check. Keep Memo Labs OS Project #1 as the separate agent Coordinator view. + + +## Automated reconciliation — gated 2026-10-19 + +The migration completed with **160 existing open organization Issues** in the PRIVATE Engineering Project; the audit covered **44 active org repositories**. The personal Project now has **22 existing open Issues** (lab-infra 6, kollio 15, foldready 1). Both Projects have the Table view "Toutes les issues" and the Board view "Kanban". No extra Issues were created. + +The workflow `.github/workflows/portfolio-projects-sync.yml` is prepared for trusted-main workflow_dispatch (dry-run by default) and a six-hourly schedule gated by **repository variable** `MEMOLABS_PROJECTS_SYNC_ENABLED=true`. The variable must remain disabled until the **Actions secret** `MEMOLABS_PROJECTS_TOKEN` has been provisioned with tightly scoped Project V2 write + repository Issues read. The current `.github` repository has not been verified to possess this secret. Do not export or reuse the developer's macOS gh OAuth token as a long-lived secret. No recurring sync can be claimed as running before the first authenticated successful scheduled execution. + +Personal Project automation requires a **separate owner-scoped permission** and must not silently reuse the organization's credential. In the interim, rerun this script with `--repo` for each personal repository and the personal Project ID `PVT_kwHOA2DkXs4Bnzqi`. + +Under GitHub Free the built-in auto-add workflow is limited to one per Project and cannot replace this cross-repository reconciliation. Existing project Status and Priorité values are preserved. The March 2026 GitHub GraphQL view API is used for Table and Board views; no separate task database is introduced. From c07f4e6e5b6ed399ae1bbdd7f8aaeef44c3c5fae Mon Sep 17 00:00:00 2001 From: Guillaume Flambard <56681566+guillaume-flambard@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:17:13 +0200 Subject: [PATCH 8/8] fix(uni): preserve sorted trusted workflow manifest --- .uni/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.uni/config.toml b/.uni/config.toml index 0dfd2cd..88ecd67 100644 --- a/.uni/config.toml +++ b/.uni/config.toml @@ -8,10 +8,10 @@ files = [ ".github/workflows/app-repo-access-smoke.yml", ".github/workflows/app-write-smoke.yml", ".github/workflows/automation-spec-ci.yml", - ".github/workflows/portfolio-projects-sync.yml", ".github/workflows/guardian-check-smoke.yml", ".github/workflows/guardian-ci-provenance-smoke.yml", ".github/workflows/land.yml", + ".github/workflows/portfolio-projects-sync.yml", ".github/workflows/release-app-auth-smoke.yml", ".github/workflows/star-issue-preflight.yml", ".github/workflows/uni-governance.yml",