Skip to content
54 changes: 54 additions & 0 deletions .github/workflows/portfolio-projects-sync.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .uni/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ files = [
".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",
Expand All @@ -28,6 +29,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",
Expand All @@ -43,6 +45,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",
Expand Down
46 changes: 46 additions & 0 deletions docs/projects-v2-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 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.


## 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.
183 changes: 183 additions & 0 deletions scripts/projects_sync.py
Original file line number Diff line number Diff line change
@@ -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())
65 changes: 65 additions & 0 deletions tests/test_projects_sync.py
Original file line number Diff line number Diff line change
@@ -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()