DEV Community

Cover image for Comment osFoundry ne pousse qu'une seule branche quand Codex CLI demande une pull request GitHub ?
BBT for BBT Français

Posted on

Comment osFoundry ne pousse qu'une seule branche quand Codex CLI demande une pull request GitHub ?

Voici l'outil que Codex CLI appelle quand il veut envoyer un change set sur GitHub ou GitLab, tel que le serveur MCP d'osFoundry le liste à n'importe quel client, connecté ou non :

{"name": "propose_gitnest_provider_publication",
 "description": "Ask the repository owner to review a composed GitNest change set, push only its dedicated branch using their linked Git provider account, and open a draft pull request. Approval rechecks permissions, risk policy, required checks, and target commit.",
 "inputSchema": {"properties": {
     "repository_id": {"type": "string"},
     "change_set_id": {"type": "string"},
     "expected_change_set_version": {"type": "integer", "minimum": 1},
     "expected_composition_oid": {"type": "string"},
     "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}},
   "required": ["repository_id", "change_set_id", "expected_change_set_version",
                "expected_composition_oid", "idempotency_key"],
   "additionalProperties": false},
 "annotations": {"readOnlyHint": false, "openWorldHint": true, "destructiveHint": true},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
Enter fullscreen mode Exit fullscreen mode

Regardez surtout ce qui manque. Pas de nom de branche, pas de titre, pas de branche cible, pas de flag draft, pas de token. Le client désigne un change set et deux faits qu'il a observés à son sujet, et tout le reste vient du côté du propriétaire, au moment où il approuve. "Push only its dedicated branch", c'est la promesse que ce post décortique. Il couvre aussi l'outil qui passe généralement en premier, propose_gitnest_file_import, et ce que son budget de 200 KB implique pour de vrais fichiers.

Aucun binaire codex n'est installé sur ce Mac, donc le côté client est un script Python, exécuté ici (Apple M1, Python 3.12 via uv, git 2.39.5). Les mesures réseau ont été faites depuis le Japon.

$ uv run --with tiktoken --with jsonschema python3 publish_sim.py
== Part 1: live tools/list (no token) ==
tools/list x5 median 505.7 ms; 59 tools, 13 GitNest
read_gitnest_repository               osfoundry:read  ro=True  openWorld=False destructive=False
propose_gitnest_file_import           osfoundry:write ro=False openWorld=False destructive=True
propose_gitnest_native_publication    osfoundry:write ro=False openWorld=True  destructive=True
propose_gitnest_provider_publication  osfoundry:write ro=False openWorld=True  destructive=True
these four = 989 of 12662 tokens
Enter fullscreen mode Exit fullscreen mode

(Les listes de champs obligatoires ont été coupées dans la sortie ci-dessus. Les tokens viennent de cl100k_base, ce n'est donc qu'une approximation pour le tokenizer propre à Codex.) Les quatre outils coûtent moins de 1,000 tokens sur les 12,662 de la liste. Une config Codex qui les nomme dans enabled_tools garde les 55 autres hors du prompt.

Pourquoi les deux outils de publication sont-ils "open world" et pas l'import ?

openWorldHint indique au client si un outil va au-delà d'un système fermé. L'import de fichiers écrit dans un dépôt GitNest que seul le projet voit. Une pull request, même native, est quelque chose dont d'autres personnes sont notifiées et sur quoi elles agissent. Codex s'appuie sur ce genre d'annotations pour décider quand s'arrêter et demander, donc les hints du serveur sont le premier maillon de la chaîne d'approbation, avant même la carte d'osFoundry.

Que peut contenir un import de fichiers ?

Le schéma répond à presque tout. Douze cas testés contre les inputSchema réels, validés avec jsonschema (Draft 2020-12) :

file_import            20 files                           ok
file_import            21 files                           REJECT maxItems
file_import            one file 100,000 chars             ok
file_import            one file 100,001 chars             REJECT maxLength
file_import            file with mode '100755'            REJECT additionalProperties
file_import            delete: {'path','delete':true}     REJECT required
file_import            idempotency_key 7 chars            REJECT minLength
native_publication     full pin                           ok
native_publication     no expected_composition_oid        REJECT required
provider_publication   expected_change_set_version 0      REJECT minimum
provider_publication   extra 'draft': false               REJECT additionalProperties
provider_publication   extra 'target_branch'              REJECT additionalProperties
Enter fullscreen mode Exit fullscreen mode

Une entrée de fichier, c'est path plus content, et rien d'autre. Pas de bits de mode, donc un script arrive sans droit d'exécution. Pas de suppression, puisqu'une suppression demanderait une entrée sans contenu. Chaque entrée ajoute ou remplace un fichier avec son texte complet, et c'est exactement ce que le propriétaire lit sur la carte d'approbation.

Il y a une limite que le schéma ne sait pas exprimer : les 200 KB au total indiqués dans la description. maxLength compte des caractères. Le budget, lui, compte des octets UTF-8 :

JA file: 100,000 chars passes maxLength; UTF-8 = 300,000 bytes
Enter fullscreen mode Exit fullscreen mode

Un fichier japonais au plafond de caractères par fichier pèse déjà une fois et demie le budget du commit entier. Un client qui ne vérifie que le schéma va construire des appels que le serveur devra refuser. Comptez les octets côté client.

Combien de cartes d'approbation donne un vrai import ?

La partie 3 du script répartit de vrais répertoires en appels d'import de moins de 20 fichiers et 200 KB, en first-fit par taille, puis compte les propositions. Chaque proposition est une carte que le propriétaire doit ouvrir :

GitNest web lib (.ts)           25 files   126,756 chars   127,210 bytes (1.00 B/char) | 2 proposals
   per proposal (files, KB): 20/115, 5/12
GitNest web components (.tsx)   22 files   274,206 chars   274,819 bytes (1.00 B/char) | 2 proposals
   per proposal (files, KB): 10/200, 12/75
Team 2 JA articles (.md)        55 files   359,213 chars   662,285 bytes (1.84 B/char) | 4 proposals
   per proposal (files, KB): 13/200, 16/196, 18/194, 8/73
Enter fullscreen mode Exit fullscreen mode

Les corpus sont les sources frontend d'osFoundry et les brouillons japonais de ce blog, lus directement sur le disque. Les petits fichiers source butent d'abord sur la limite de 20 fichiers. Les gros composants et le texte non latin butent d'abord sur le budget en octets. Lire "KB" comme 1,000 ou 1,024 octets n'a rien changé pour ces trois ensembles.

L'autre point à noter, c'est le nombre de commits. Quatre propositions, ce sont quatre commits sur la branche, approuvés un par un, et le propriétaire peut refuser n'importe lequel. Si les fichiers n'ont de sens qu'ensemble, dites-le dans chaque message de commit, ou gardez l'import assez petit pour tenir sur une seule carte.

Que veut dire "push only its dedicated branch" côté git ?

Le push vers le provider qu'osFoundry fait côté serveur n'est pas public. Celui de l'app desktop, utilisé par le bouton Push Change Set Branch de GitNest, l'est : gitnest_push_change_set_branch dans gitnest_desktop.rs. Il prend les valeurs renvoyées par un preflight de publication et fait ceci, dans l'ordre :

  1. Lit l'URL de fetch et l'URL de push d'origin et refuse si elles pointent vers des dépôts différents, ou pas vers le provider, l'owner et le repo indiqués par le preflight.
  2. Exige que la branche soit exactement osfoundry/change-set-<id>, avec <id> un UUID.
  3. Exige que le marqueur soit exactement GitNest-Change-Set: <id>.
  4. Exige que l'OID de composition fasse de 4 à 64 caractères hexadécimaux, puis lance git cat-file -e <oid>^{commit}.
  5. Lance git update-ref refs/heads/<branch> <oid> <old>, où <old> est ce que contenait la ref au moment de la lecture, ou vide si elle n'existait pas.
  6. Pousse une seule refspec : git push origin refs/heads/<branch>:refs/heads/<branch>.

Le script reconstruit tout ça contre un dépôt bare qui joue le rôle de GitHub. Le clone contient deux choses que le propriétaire n'a pas poussées : une branche locale wip/secret-pricing et un commit en plus sur release/2.4. Le commit composé est construit sans worktree (un index jetable, hash-object, update-index --cacheinfo, write-tree, commit-tree) :

remote refs before: 3  (dana/refactor-cart, main, release/2.4)
`git push --all` would change 2 remote refs: ['release/2.4', 'wip/secret-pricing']
explicit refspec push changed 1 ref: osfoundry/change-set-5b0c2e8e-3f6a-4d2b-9a71-0c4e9d1f7a23 -> 73e59f27bc4f
untouched: True; marker in pushed commit: True
Enter fullscreen mode Exit fullscreen mode

Cette deuxième ligne est toute la raison d'être de la refspec explicite. Un push pratique depuis le clone du propriétaire aurait publié, en même temps que le change set, un hotfix non relu et une branche privée. La refspec explicite ne déplace qu'une ref, dont le nom est dérivé de l'ID du change set, donc elle ne peut entrer en collision avec la branche de personne.

Qu'est-ce qui est refusé ?

branch name 'codex/ci' instead of contract     refused: publication branch does not match the preflight contract
marker for another change set                  refused: publication marker does not match the preflight contract
OID 'HEAD' instead of hex                      refused: composition OID must be a hex commit id
hex OID that is not in the clone               refused: fatal: Not a valid object name deadbeefdeadbeefdeadbeefdeadbeefdeadbeef^{commit}
update-ref with a stale expected old value     exit 128: fatal: update_ref failed for ref 'refs/heads/osfoundry/change-set-5b0c2e8e-3f6a-
push URL differs from fetch URL                refused: origin fetch and push remotes point at different repositories
Enter fullscreen mode Exit fullscreen mode

Le cas "HEAD" a l'air pinailleur, mais il ne l'est pas. Un nom symbolique se résout vers ce qui est checkout au moment du push, ce qui n'est pas forcément ce qui a été composé et relu. Un OID hexadécimal ne peut désigner qu'un seul commit. Le update-ref avec une ancienne valeur périmée, c'est le compare-and-swap de git lui-même : si la branche locale a bougé entre la lecture et l'écriture, l'écriture échoue au lieu de l'écraser en silence. La vérification des URLs séparées bouche un trou plus discret : git remote set-url --push peut envoyer les push ailleurs que là d'où viennent les fetch, et le code exige que les deux désignent le même dépôt.

Qu'apporte la revérification à l'approbation ?

Les étapes git protègent le push. Les deux champs expected_ protègent la décision. La partie 5 du script reconstruit la revérification à partir des descriptions des outils ("Approval rechecks permissions, risk policy, required checks, and target commit") :

as proposed                          publish
recomposed to v4 before the click    refused: change set is v4, card says v3
lint re-ran and failed               refused: required checks not passed: ['lint']
Enter fullscreen mode Exit fullscreen mode

Codex a lu la version 3 et le commit 73e59f27bc4f…, et la carte du propriétaire affiche les deux. Si quelqu'un recompose le change set entre-temps, approuver la carte publierait quelque chose que personne n'a regardé, donc la proposition échoue. La version seule ne suffit pas, car deux compositions de la même version peuvent différer. L'OID seul ne suffit pas non plus, car il ne dit rien de l'édition du change set que le propriétaire avait en tête. Épingler les deux coûte deux champs au client.

Combien ça coûte ?

20 runs, local bare remote: compose median 114.2 ms, checks+update-ref+push median 140.0 ms (max 430.0)
Enter fullscreen mode Exit fullscreen mode

Les six vérifications et le push ajoutent environ 140 ms avec un remote bare local. Face à un vrai provider, c'est l'aller-retour réseau qui domine. Le script complet, 5 appels réels à tools/list et 21 publications compris, a pris 11.7 s en temps réel et 3.5 s de CPU utilisateur.

Si vous le câblez vous-même

  • Laissez l'agent nommer le change set et ce qu'il a observé (version et commit), jamais la branche, le titre, la cible ou le token. Dérivez le nom de branche de l'ID du change set.
  • Poussez avec une seule refspec explicite. Jamais --all, --mirror ou un git push tout court depuis un clone qu'une personne utilise aussi.
  • Écrivez la ref locale avec une ancienne valeur attendue, et exigez que les URLs de fetch et de push désignent le même dépôt.
  • N'acceptez que des OIDs hexadécimaux pour tout ce qui sera publié. Résolvez les noms symboliques avant la revue, pas après.
  • Exprimez le budget d'import en octets et vérifiez-le côté client. Les limites en caractères du schéma ne rattrapent pas le texte non latin.
  • Marquez avec openWorldHint: true les outils qui atteignent d'autres personnes, pour que la demande d'approbation du client se déclenche avant la vôtre.

La façon dont change sets, checks et publication s'articulent dans un dépôt est décrite sur la page GitNest, et le côté protocole dans l'entrée du glossaire sur MCP.

Top comments (0)