diff --git a/DOC.md b/DOC.md index 9b2e528..a854c6e 100644 --- a/DOC.md +++ b/DOC.md @@ -191,10 +191,17 @@ file content, not substring `LIKE`). ### `repository` nested fields (all resolved on the `RepositoryObject` type) `ownerLogin`, `issues`, `pullRequests`, `workflowRuns`, `packages`, -`secretNames`, `branches`, `tree(ref, path)`, `wikiPages`, `wikiPage(page)`, -`commits(ref, limit)`, `activity(limit)`, `collaborators`, `labels`, -`milestones`, `projects` (with nested `columns` → `cards` → `issue`), -`branchProtectionRules`, `webhooks`. +`secretNames`, `branches`, `tree(ref, path)`, `fileContent(ref, path)` (a +single file's content — `content` as UTF-8 text plus `contentBase64` always, +`isBinary`/`size`; returns `null` for a missing path/ref or a file over +10MiB), `wikiPages`, `wikiPage(page)`, `commits(ref, limit)`, +`activity(limit)`, `collaborators`, `labels`, `milestones`, `projects` (with +nested `columns` → `cards` → `issue`), `branchProtectionRules`, `webhooks`. + +### `issue` nested fields (all resolved on the `IssueObject` type) + +`labels`, `milestone`, `comments` (posted via `commentOnIssue`, oldest +first). ### Mutations, grouped diff --git a/JOURNAL.md b/JOURNAL.md index abc42ba..524d35c 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -44,3 +44,7 @@ Alternative Rust à GitHub/Forgejo/GitLab/Coder. Objectif 22h00. 5. **Dev-workspace hosting par le runner standalone**: le `kind = 'dev_workspace_action'` était réservé mais mort — maintenant câblé bout en bout: `createDevWorkspace(..., onRunner: true)` pousse un `runner_jobs` (nouvelle colonne `result` pour faire remonter `container_id`/output, migration `10_dev_workspace_runner.sql`), le runner le récupère et l'exécute contre **son propre** Docker (nouveau `crates/runner/src/dev_workspace_poll.rs`, plus plausible stub), rapporte le résultat, la mutation poll la DB (pas de pub/sub, juste polling 300ms/20s timeout) et renvoie l'objet à jour. `delete`/`exec` suivent le même chemin pour un workspace déjà hébergé par un runner. Honnêteté: `start`/`stop` et surtout le **reverse-proxy HTTP live** vers un workspace hébergé sur un runner restent non supportés (le conteneur est sur un Docker daemon que `server` n'a aucun moyen réseau d'atteindre — ça demanderait un vrai tunnel inverse runner→server, hors scope ici) — documenté comme lacune connue plutôt que de faire semblant que ça marche. 6. **Alternative safe au DinD**: en creusant, `server`/`runner` ne faisaient déjà **pas** de vrai Docker-in-Docker (pas de `dockerd` imbriqué) — juste un montage du socket Docker de l'**hôte** ("Docker outside of Docker"), ce qui est en pratique un risque équivalent (accès root-like à l'hôte via `-v /:/host`). `DOCKER_SOCKET_PATH` existait déjà en config mais n'était jamais branché nulle part (juste loggé) — maintenant réellement câblé (`Executor::new_with_socket`, `WorkspaceManager::connect_socket`, via `bollard::Docker::connect_with_socket`). Recherche web: **Podman rootless** (`systemctl --user enable --now podman.socket`, API compatible Docker, isolation par user-namespace, zéro changement de code requis côté Genome) recommandé en premier, **Sysbox** (`nestybox/sysbox`) documenté pour le cas où un workflow a réellement besoin de nested Docker (ex: un step qui fait `docker build`). Tout documenté dans `DOC.md` §2a, rien d'actif par défaut (il faut un socket disponible pour un `docker compose up` zéro-config) mais le knob marche vraiment maintenant. - Tests ajoutés: 6 nouveaux tests unitaires `marketplace` (parsing `ActionRef`, résolution d'inputs, substitution), 2 nouveaux tests `git-core` (rename repo+wiki avec rollback/collision, idempotence du backfill hook). `cargo test --workspace`: seuls les 6 tests d'intégration qui construisent un `actions::Executor`/`dev_env::WorkspaceManager` échouent, et uniquement à cause de l'absence de `/var/run/docker.sock` **dans ce bac à sable de session** (pas de démon Docker démarrable, `systemd` absent) — confirmé non lié à mes changements (ces tests appellent le même `Executor::new()`/`connect_local()` qu'avant, code non modifié sur ce chemin). Pushé (ec8ec2e). +- **(reprise de session, 21 juillet)** — Audit ciblé de l'API GraphQL demandé par l'utilisateur ("flag les vrais gaps, n'en invente pas"): confirmé par lecture de code (pas de supposition) que deux capacités manquaient réellement, malgré le socle bas-niveau déjà présent: + 1. **Lecture de contenu de fichier**: `RepoManager::read_file_at_ref` existait dans `git-core` depuis la vague FTS5/marketplace mais n'était jamais appelé que côté serveur (lecture interne du YAML de workflow pour `triggerWorkflowDispatch`) — aucune query GraphQL ne l'exposait. `Repository.tree(ref, path)` ne renvoie que des métadonnées (nom/chemin/taille/oid), jamais les octets. Ajouté `Repository.fileContent(ref, path)`: nouveau type `FileContentObject` (`content` UTF-8 si le fichier n'est pas binaire, `contentBase64` toujours présent, `isBinary`, `size`), `null` si le path/ref ne résout à rien ou si le fichier dépasse 10MiB (garde-fou coût, pas de troncature silencieuse). + 2. **Lecture des commentaires d'issue**: `commentOnIssue` insérait dans `issue_comments` et renvoyait le commentaire tout juste créé, mais rien ne relisait cette table ensuite — `IssueObject` n'exposait que `labels`/`milestone`. Ajouté `IssueObject.comments` (tri `created_at ASC`), même pattern que `labels` (query_as sur `entity::issue_comment::Model`, déjà juste un dérivé `Deserialize`, pas besoin d'un nouvel impl `From<&mut Row>`). + - `rustc` remis à jour 1.94→1.97 (hiqlite 0.14 l'exige, déjà rencontré lors du chantier précédent). `cargo check/test --workspace` et `DOC.md` (§4, nouvelles sections nested-fields `repository`/`issue`) mis à jour en conséquence. diff --git a/crates/graphql-api/src/types.rs b/crates/graphql-api/src/types.rs index ac66832..867ab1a 100644 --- a/crates/graphql-api/src/types.rs +++ b/crates/graphql-api/src/types.rs @@ -1,4 +1,5 @@ use async_graphql::{ComplexObject, Context, SimpleObject}; +use base64::Engine; use chrono::{DateTime, Utc}; use hiqlite::params; use uuid::Uuid; @@ -191,6 +192,40 @@ impl RepositoryObject { Ok(entries.into_iter().map(TreeEntryObject::from).collect()) } + /// Reads a single file's content at a given ref. Returns `null` if the + /// ref/path doesn't resolve to a regular file. Caps at 10MiB to avoid an + /// API client pulling an oversized blob into a single GraphQL response; + /// larger files are reported as `null` rather than truncated. + async fn file_content( + &self, + ctx: &Context<'_>, + #[graphql(name = "ref", default = "\"HEAD\".to_string()")] r#ref: String, + path: String, + ) -> async_graphql::Result> { + const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; + let app = ctx.data::()?; + let bytes = match app + .repo_manager + .read_file_at_ref(&self.owner_login, &self.name, &r#ref, &path) + { + Ok(bytes) if bytes.len() <= MAX_FILE_BYTES => bytes, + _ => return Ok(None), + }; + let is_binary = bytes.contains(&0u8); + let content = if is_binary { + None + } else { + String::from_utf8(bytes.clone()).ok() + }; + Ok(Some(FileContentObject { + path, + size: bytes.len() as i64, + is_binary, + content, + content_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), + })) + } + async fn wiki_pages(&self, ctx: &Context<'_>) -> async_graphql::Result> { let app = ctx.data::()?; let pages = app @@ -407,6 +442,19 @@ impl IssueObject { .next(); Ok(milestone.map(MilestoneObject::from)) } + + /// Comments posted on this issue via `commentOnIssue`, oldest first. + async fn comments(&self, ctx: &Context<'_>) -> async_graphql::Result> { + let app = ctx.data::()?; + let comments = app + .db + .query_as::( + "SELECT * FROM issue_comments WHERE issue_id = ?1 ORDER BY created_at ASC", + params!(self.id.to_string()), + ) + .await?; + Ok(comments.into_iter().map(IssueCommentObject::from).collect()) + } } #[derive(SimpleObject, Clone)] @@ -633,6 +681,18 @@ impl From for TreeEntryObject { } } +/// A single file's content read at a ref, returned by `Repository.fileContent`. +#[derive(SimpleObject, Clone)] +pub struct FileContentObject { + pub path: String, + pub size: i64, + pub is_binary: bool, + /// UTF-8 text content, or `null` if the file is binary / not valid UTF-8. + pub content: Option, + /// Base64-encoded raw bytes, always present regardless of `isBinary`. + pub content_base64: String, +} + #[derive(SimpleObject, Clone)] pub struct AuthPayload { pub token: String,