DEV Community

Cover image for ¿Cómo hace osFoundry para subir una sola rama cuando Codex CLI pide un pull request en GitHub?
BBT for BBT Español

Posted on

¿Cómo hace osFoundry para subir una sola rama cuando Codex CLI pide un pull request en GitHub?

Esta es la herramienta que llama Codex CLI cuando quiere llevar un change set a GitHub o GitLab, tal como el servidor MCP de osFoundry la lista a cualquier cliente, haya iniciado sesión o no:

{"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

Fíjate en lo que no aparece. Ni nombre de rama, ni título, ni rama de destino, ni flag de draft, ni token. El cliente nombra un change set y dos datos que observó sobre él, y todo lo demás lo pone el propietario en el momento de aprobar. "Push only its dedicated branch" es la promesa que este post desmonta. También repasa la herramienta que suele ir antes, propose_gitnest_file_import, y lo que su presupuesto de 200 KB significa con archivos reales.

En este Mac no hay ningún binario de codex instalado, así que el lado del cliente es un script de Python ejecutado aquí (Apple M1, Python 3.12 vía uv, git 2.39.5). Las cifras de red se midieron desde Japón.

$ 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

(Las listas de campos obligatorios están recortadas en la salida. Los tokens son de cl100k_base, así que son una aproximación para el tokenizer propio de Codex.) Las cuatro herramientas cuestan menos de 1,000 tokens de los 12,662 de la lista. Una configuración de Codex que las nombre en enabled_tools deja las otras 55 fuera del prompt.

¿Por qué las dos herramientas de publicación son "open world" y la importación no?

openWorldHint le indica al cliente si una herramienta llega más allá de un sistema cerrado. La importación de archivos escribe en un repositorio de GitNest que solo ve el proyecto. Un pull request, aunque sea nativo, es algo que notifica a otras personas y sobre lo que esas personas actúan. Codex usa anotaciones como estas para decidir cuándo parar y preguntar, así que las pistas del servidor son el primer eslabón de la cadena de aprobación, antes incluso de la tarjeta de osFoundry.

¿Qué puede llevar una importación de archivos?

El schema responde a casi todo. Doce casos contra los inputSchema reales, validados con 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

Una entrada de archivo es path más content, y nada más. No hay bits de modo, así que un script llega sin permiso de ejecución. Tampoco hay borrados, porque un borrado necesitaría una entrada sin contenido. Cada entrada añade o reemplaza un archivo con su texto completo, que además es exactamente lo que el propietario lee en la tarjeta de aprobación.

Hay un límite que el schema no puede expresar: los 200 KB en total que menciona la descripción. maxLength cuenta caracteres. El presupuesto cuenta bytes UTF-8:

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

Un archivo en japonés que llega al tope de caracteres por archivo ya ocupa una vez y media el presupuesto de todo el commit. Un cliente que solo valide contra el schema va a construir llamadas que el servidor tendrá que rechazar. Cuenta los bytes en el cliente.

¿En cuántas tarjetas de aprobación acaba una importación real?

La parte 3 del script reparte directorios reales en llamadas de importación de menos de 20 archivos y 200 KB, con first-fit por tamaño, y cuenta las propuestas. Cada propuesta es una tarjeta que el propietario tiene que abrir:

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

Los corpus son el código del frontend de osFoundry y los borradores en japonés de este blog, leídos directamente del disco. Los archivos de código pequeños topan primero con el límite de 20 archivos. Los componentes grandes y el texto no latino topan primero con el presupuesto de bytes. Interpretar "KB" como 1,000 o como 1,024 bytes no cambió nada en estos tres conjuntos.

La otra cosa en la que fijarse es el número de commits. Cuatro propuestas son cuatro commits en la rama, aprobados uno a uno, y el propietario puede rechazar cualquiera de ellos. Si los archivos solo tienen sentido juntos, dilo en cada mensaje de commit, o mantén la importación lo bastante pequeña como para que quepa en una sola tarjeta.

¿Qué significa "push only its dedicated branch" en git?

El push al proveedor que hace osFoundry en el servidor no es público. El de la app de escritorio, el que usa el botón Push Change Set Branch de GitNest, sí lo es: gitnest_push_change_set_branch en gitnest_desktop.rs. Recibe los valores que devolvió un preflight de publicación y hace lo siguiente, en este orden:

  1. Lee la URL de fetch y la URL de push de origin y se niega a seguir si apuntan a repositorios distintos, o si no apuntan al proveedor, owner y repo que indicó el preflight.
  2. Exige que la rama sea exactamente osfoundry/change-set-<id>, con <id> un UUID.
  3. Exige que el marcador sea exactamente GitNest-Change-Set: <id>.
  4. Exige que el OID de la composición tenga entre 4 y 64 caracteres hexadecimales y luego ejecuta git cat-file -e <oid>^{commit}.
  5. Ejecuta git update-ref refs/heads/<branch> <oid> <old>, donde <old> es lo que contenía la ref al leerla, o vacío si no existía.
  6. Hace push de un único refspec: git push origin refs/heads/<branch>:refs/heads/<branch>.

El script reconstruye todo esto contra un repositorio bare que hace de GitHub. El clon tiene dos cosas que el propietario no ha subido: una rama local wip/secret-pricing y un commit de más en release/2.4. El commit compuesto se construye sin worktree (un índice temporal, 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

Esa segunda línea es la razón de ser del refspec explícito. Un push cómodo desde el clon del propietario habría publicado, junto con el change set, un hotfix sin revisar y una rama privada. El refspec explícito mueve una sola ref, cuyo nombre se deriva del ID del change set, así que no puede chocar con la rama de nadie.

¿Qué se rechaza?

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

El caso de "HEAD" parece quisquilloso, pero no lo es. Un nombre simbólico se resuelve a lo que esté en checkout en el momento del push, que no tiene por qué ser lo que se compuso y se revisó. Un OID hexadecimal solo puede referirse a un commit. El update-ref con un valor antiguo desactualizado es el compare-and-swap del propio git: si la rama local se movió entre la lectura y la escritura, la escritura falla en lugar de sobrescribirla en silencio. La comprobación de URLs separadas cierra un agujero más discreto: git remote set-url --push puede mandar los push a un sitio distinto del que vienen los fetch, y el código exige que ambas URLs apunten al mismo repositorio.

¿Qué aporta la recomprobación al aprobar?

Los pasos de git protegen el push. Los dos campos expected_ protegen la decisión. La parte 5 del script reconstruye la recomprobación a partir de las descripciones de las herramientas ("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 leyó la versión 3 y el commit 73e59f27bc4f…, y la tarjeta del propietario muestra ambos. Si alguien recompone el change set entretanto, aprobar la tarjeta publicaría algo que nadie ha revisado, así que la propuesta falla. La versión sola no basta, porque dos composiciones de la misma versión podrían ser distintas. El OID solo tampoco, porque no dice a qué edición del change set se refería el propietario. Fijar ambos le cuesta al cliente dos campos.

¿Cuánto cuesta?

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

Las seis comprobaciones y el push añaden unos 140 ms contra un remoto bare local. Con un proveedor real, lo que domina es el viaje de ida y vuelta por la red. El script completo, incluidas 5 llamadas reales a tools/list y 21 publicaciones, tardó 11.7 s de tiempo real y 3.5 s de CPU de usuario.

Si lo montas por tu cuenta

  • Deja que el agente nombre el change set y lo que observó (versión y commit), nunca la rama, el título, el destino ni el token. Deriva el nombre de la rama del ID del change set.
  • Haz push con un único refspec explícito. Nunca --all, --mirror ni un git push a secas desde un clon que también usa una persona.
  • Escribe la ref local con un valor antiguo esperado, y exige que las URLs de fetch y push apunten al mismo repositorio.
  • Acepta solo OIDs hexadecimales para cualquier cosa que se vaya a publicar. Resuelve los nombres simbólicos antes de la revisión, no después.
  • Expresa el presupuesto de importación en bytes y compruébalo en el cliente. Los límites de caracteres del schema no detectan el texto no latino.
  • Marca con openWorldHint: true las herramientas que llegan a otras personas, para que el aviso de aprobación del propio cliente salte antes que el tuyo.

Cómo encajan los change sets, las comprobaciones y la publicación dentro de un repositorio se explica en la página de GitNest, y la parte del protocolo está en la entrada del glosario sobre MCP.

Top comments (0)