Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

An agent can put an image in a pull request, an issue or a comment without committing it: on flagon-io/session-lab #2 a cloud session asked to show a screenshot of its preview in the pull request's description found no way to upload one (not in g1t --help, g1t api --list or the docs), so it committed the PNG, linked it at that commit and removed it in a second commit. The site has no comment-image upload either; images only ever came from repository files. Now upload_attachment (POST /repos/{owner}/{repo}/attachments, issues:write, the issue tool's attach action over MCP) takes name, data (the file base64-encoded, as upload_sarif takes its file) and an optional content_type, and answers in snake_case with id, name, url, markdown (![name](url), ready to paste), content_type and size. The work service decides, as add_comment does: a confirmed account that can read the repository, which is not archived. The type is read from the bytes (PNG, JPEG, GIF or WebP; a content_type that says otherwise is refused), at most 10 MB; too large is the new FailureCode too_large (413), another type the new unsupported_type (415), in Rust and TS contracts and the OpenAPI error codes. The bytes go in the usercontent namespace (AVATARS, now bound to work) under attachments/<id>, where the id is 256 random bits so the address is its permission, as Docs files' is; a row in the new attachments table (migration 0048) names the repository, and repo.purged deletes the bytes, then the rows. The usercontent origin serves /attachments/<id> as it serves avatars (stored type, nosniff, sandboxed). The API forms url from USERCONTENT_URL as the site does (g1tusercontent.com for g1t.sh, SITE_URL/-/usercontent elsewhere; the self-host config now passes it to the API). The CLI has g1t attach FILE... (prints each image's Markdown; --json the fields, a list for several) and --attach FILE (repeatable) on g1t issue create, issue comment, pr create, pr comment and pr ready, which uploads and appends the Markdown after the text. A session's run credential gets upload_attachment through issues:write, bound to its own repository (tested), and the cloud session's system prompt says to upload screenshots with g1t attach and never commit images just to link them. Docs: the CLI guide (Images in descriptions and comments), pull requests (Images), sessions, the MCP reference, self-hosting's USERCONTENT_URL row, reference.json and the regenerated openapi.json, CLI ops table and CLI reference. Deploy order: apply work migration 0048 and deploy work (new AVATARS binding), then the site (usercontent serves /attachments), then api, then integrations (prompt) and the docs; g1t attach and --attach ship with the next CLI release, which cloud sessions need before the prompt's advice works. Checked: cargo test -p g1t-contracts (319), -p g1t-work (209), -p g1t-api (211; openapi regenerated with G1T_WRITE_OPENAPI), -p g1t (112 + 51; ops_table and cli_reference regenerated with G1T_WRITE_OPS and G1T_WRITE_DOCS), -p g1t-integrations (76); cargo check -p g1t-api, -p g1t-work and -p g1t-integrations --target wasm32-unknown-unknown; node --test apps/web/app/lib/token-scopes.test.ts.

G1t-Session:
ses_01m4z6ztqreyntc2ns37hjfg8m
syntaqxcommitted Parent62eb9c0Browse files
36 files+387−1620 files not counted0/36 viewed
+28−2
1313 pub const HOSTED_SITE: &str = "https://g1t.sh";
1414 pub const HOSTED_API: &str = "https://api.g1t.sh";
1515 pub const HOSTED_MCP: &str = "https://mcp.g1t.sh";
16+pub const HOSTED_USERCONTENT: &str = "https://g1tusercontent.com";
1617
1718 #[derive(Clone, Debug, PartialEq, Eq)]
1819 pub struct Addresses {
2223 pub api: String,
2324 /// MCP_URL: the MCP server, and the OAuth protected resource.
2425 pub mcp: String,
26+ /// USERCONTENT_URL: where attachments are served (apps/web/workers/usercontent.ts),
27+ /// as the site reads it (apps/web/app/lib/addresses.ts): unset, g1tusercontent.com
28+ /// for g1t.sh, and a path on the site (`SITE_URL/-/usercontent`) for any other.
29+ pub usercontent: String,
2530 }
2631
2732 impl Default for Addresses {
3035 site: HOSTED_SITE.to_owned(),
3136 api: HOSTED_API.to_owned(),
3237 mcp: HOSTED_MCP.to_owned(),
38+ usercontent: HOSTED_USERCONTENT.to_owned(),
3339 }
3440 }
3541 }
4248
4349 impl Addresses {
4450 pub fn from_settings(site: Option<String>, api: Option<String>, mcp: Option<String>) -> Addresses {
51+ let site = setting(site, HOSTED_SITE);
52+ let usercontent = if site == HOSTED_SITE { HOSTED_USERCONTENT.to_owned() } else { format!("{site}/-/usercontent") };
4553 Addresses {
46− site: setting(site, HOSTED_SITE),
4754 api: setting(api, HOSTED_API),
4855 mcp: setting(mcp, HOSTED_MCP),
56+ usercontent,
57+ site,
58+ }
59+ }
60+
61+ /// With USERCONTENT_URL, when it is set: the site's own origin is
62+ /// never the usercontent origin, a path on it is.
63+ pub fn with_usercontent(mut self, usercontent: Option<String>) -> Addresses {
64+ let given = setting(usercontent, "");
65+ if !given.is_empty() {
66+ self.usercontent = if given == self.site { format!("{}/-/usercontent", self.site) } else { given };
4967 }
68+ self
5069 }
5170
5271 pub fn from_env(env: &Env) -> Addresses {
5372 let var = |name: &str| env.var(name).ok().map(|value| value.to_string());
54− Addresses::from_settings(var("SITE_URL"), var("API_URL"), var("MCP_URL"))
73+ Addresses::from_settings(var("SITE_URL"), var("API_URL"), var("MCP_URL")).with_usercontent(var("USERCONTENT_URL"))
5574 }
5675
5776 /// What a client is told when it must sign in first (RFC 9728).
107126 "Bearer resource_metadata=\"https://mcp.g1t.sh/.well-known/oauth-protected-resource\""
108127 );
109128 assert_eq!(hosted.git_remote("acme", "rocket"), "https://g1t.sh/acme/rocket.git");
129+ assert_eq!(hosted.usercontent, "https://g1tusercontent.com");
110130 let on_mcp = Url::parse("https://mcp.g1t.sh/").unwrap();
111131 assert_eq!(hosted.mcp_path(&on_mcp).as_deref(), Some("/"));
112132 let on_api = Url::parse("https://api.g1t.sh/user").unwrap();
132152 assert_eq!(own.mcp_path(&url("http://localhost:8789/user")), None);
133153 assert_eq!(own.mcp_path(&url("http://127.0.0.1:8789/mcp")).as_deref(), Some("/"), "by path, whatever the host");
134154 assert_eq!(own.git_remote("acme", "rocket"), "http://localhost:8787/acme/rocket.git");
155+ assert_eq!(own.usercontent, "http://localhost:8787/-/usercontent");
156+ let files = own.clone().with_usercontent(Some("https://files.example.net/".into()));
157+ assert_eq!(files.usercontent, "https://files.example.net");
158+ let same = own.clone().with_usercontent(Some("http://localhost:8787".into()));
159+ assert_eq!(same.usercontent, "http://localhost:8787/-/usercontent");
160+ assert_eq!(own.clone().with_usercontent(Some(" ".into())), own);
135161 }
136162 }
+7−1
360360 Op::EditComment,
361361 Op::DeleteComment,
362362 Op::React,
363+ Op::UploadAttachment,
363364 Op::ListIssueLabels,
364365 Op::AddIssueLabels,
365366 Op::SetIssueLabels,
738739 Op::EditComment => "Edit a comment",
739740 Op::DeleteComment => "Delete a comment",
740741 Op::React => "React with an emoji",
742+ Op::UploadAttachment => "Upload an attachment",
741743 Op::ReviewPullRequest => "Review a pull request",
742744 Op::ListPullRequests => "List pull requests",
743745 Op::GetPullRequest => "Get a pull request",
11071109 if op != Op::Whoami {
11081110 responses.insert("422".into(), error_response("The input is not valid."));
11091111 }
1112+ if op == Op::UploadAttachment {
1113+ responses.insert("413".into(), error_response("The file is over 10 MB (`too_large`)."));
1114+ responses.insert("415".into(), error_response("The file is not a PNG, JPEG, GIF or WebP image by its bytes, or not the content_type given (`unsupported_type`)."));
1115+ }
11101116 // Public data can be read without a token; everything else needs one.
11111117 let scope: Vec<&str> = scope_for(op.name()).map(|scope| scope.as_str()).into_iter().collect();
11121118 let security = if op.needs_user() {
12821288 })
12831289 })
12841290 .collect();
1285− let codes = ["unauthenticated", "payment_required", "forbidden", "not_found", "conflict", "invalid"];
1291+ let codes = ["unauthenticated", "payment_required", "forbidden", "not_found", "conflict", "invalid", "too_large", "unsupported_type"];
12861292 json!({
12871293 "openapi": "3.1.0",
12881294 "info": {
+59−1
191191 EditComment,
192192 DeleteComment,
193193 React,
194+ UploadAttachment,
194195 ReviewPullRequest,
195196 ListPullRequests,
196197 GetPullRequest,
387388 call(service, method, args).await
388389 }
389390
391+/// What `upload_attachment` answers: the attachment as the work service
392+/// kept it, with its address on the usercontent origin and the Markdown
393+/// that shows it.
394+pub(crate) fn attachment_answer(usercontent: &str, attachment: &g1t_contracts::attachments::Attachment) -> Value {
395+ let url = format!("{usercontent}{}", attachment.path);
396+ json!({
397+ "id": attachment.id,
398+ "name": attachment.name,
399+ "url": url,
400+ "markdown": g1t_contracts::attachments::attachment_markdown(&attachment.name, &url),
401+ "content_type": attachment.content_type,
402+ "size": attachment.size,
403+ })
404+}
405+
390406 /// Commands given the deprecated way, as `checks` or `acceptance_checks`.
391407 fn deprecated_checks(input: &Value) -> Vec<String> {
392408 let mut checks = strings(input, "checks").unwrap_or_default();
812828 }
813829
814830 impl Op {
815− pub const ALL: [Op; 408] = [
831+ pub const ALL: [Op; 409] = [
816832 Op::Whoami,
817833 Op::GetWorkspace,
818834 Op::CreateWorkspace,
887903 Op::EditComment,
888904 Op::DeleteComment,
889905 Op::React,
906+ Op::UploadAttachment,
890907 Op::ReviewPullRequest,
891908 Op::ListPullRequests,
892909 Op::GetPullRequest,
13041321 Op::EditComment => "edit_comment",
13051322 Op::DeleteComment => "delete_comment",
13061323 Op::React => "react",
1324+ Op::UploadAttachment => "upload_attachment",
13071325 Op::ReviewPullRequest => "review_pull_request",
13081326 Op::ListPullRequests => "list_pull_requests",
13091327 Op::GetPullRequest => "get_pull_request",
16711689 Op::React => {
16721690 "React to an issue or a pull request with an emoji: to its description, or with comment_id to one of its comments. content is one of +1, -1, laugh, hooray, confused, heart, rocket and eyes; remove true takes your reaction back. Anyone who can read the repository may. Answers with the reactions it has now, each with its count and the usernames of who reacted (`g1t` for g1t, which reacts eyes to what it was asked to take on). get_issue and get_pull_request give each comment's reactions, and the description's as reactions."
16731691 }
1692+ Op::UploadAttachment => {
1693+ "Upload an image to show in an issue's or a pull request's description or in a comment: a screenshot of a preview, say, instead of committing it to the repository. data is the file base64-encoded, name its file name (the image's alt text), content_type optional (checked against the bytes). PNG, JPEG, GIF and WebP of at most 10 MB; a larger file is refused with 413 too_large, another type (SVG, PDF, anything its bytes do not show to be one of those) with 415 unsupported_type. Anyone who may comment on the repository may. Returns its id, url (on the usercontent origin, g1tusercontent.com on g1t.sh), name, size, content_type, and markdown (`![name](url)`) to paste into the body or summary of create_issue, update_issue, add_comment, create_pull_request or mark_pull_request_ready. Its address is its permission: anyone given it can see the image, even one from a private repository. Over MCP the file travels in the tool call, so keep it small (a few hundred KB); `g1t attach FILE` uploads one from disk and prints the markdown."
1694+ }
16741695 Op::ReviewPullRequest => {
16751696 "Give a verdict on a pull request: approve it, or request changes and say what. Read get_pull_request_changes first. You cannot review a pull request you opened, or one g1t made for you (you are its requested_by)."
16761697 }
27442765 })),
27452766 &["repo", "number", "content"],
27462767 ),
2768+ Op::UploadAttachment => object(
2769+ json!({
2770+ "repo": repo_schema(),
2771+ "name": {
2772+ "type": "string",
2773+ "description": "The file's name, such as preview.png: the image's alt text in the markdown.",
2774+ },
2775+ "content_type": {
2776+ "type": "string",
2777+ "enum": g1t_contracts::attachments::ATTACHMENT_TYPES,
2778+ "description": "What the file is. Optional: it is read from the bytes, and refused when it says otherwise.",
2779+ },
2780+ "data": {
2781+ "type": "string",
2782+ "contentEncoding": "base64",
2783+ "description": "The file, base64-encoded: PNG, JPEG, GIF or WebP of at most 10 MB.",
2784+ },
2785+ }),
2786+ &["repo", "name", "data"],
2787+ ),
27472788 Op::ReviewPullRequest => object(
27482789 numbered(json!({
27492790 "verdict": { "type": "string", "enum": ["approve", "request_changes"] },
53305371 };
53315372 pass(work, "react", &asked).await
53325373 }
5374+ Op::UploadAttachment => {
5375+ let asked = g1t_contracts::attachments::UploadAttachmentArgs {
5376+ actor: actor(),
5377+ repo,
5378+ name: text(input, "name"),
5379+ content_type: optional_text(input, "content_type"),
5380+ data: text(input, "data"),
5381+ };
5382+ if asked.data.trim().is_empty() {
5383+ return failed(FailureCode::Invalid, "Give data: the file, base64-encoded.");
5384+ }
5385+ let kept: Outcome<g1t_contracts::attachments::Attachment> = call(work, "upload_attachment", &asked).await?;
5386+ match kept {
5387+ Outcome::Ok(attachment) => ok(&attachment_answer(&services.addresses.usercontent, &attachment)),
5388+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
5389+ }
5390+ }
53335391 Op::EditComment | Op::DeleteComment => {
53345392 let asked = CommentActionArgs {
53355393 actor: actor(),
+20−0
15901590 },
15911591 "notes": "Answers `204` with no body. Its author, or someone with the Maintain role or higher. A review that approved or requested changes answers `409`: edit it instead."
15921592 },
1593+ "upload_attachment": {
1594+ "params": {
1595+ "owner": "flagon-io",
1596+ "name": "session-lab"
1597+ },
1598+ "request": {
1599+ "name": "preview.png",
1600+ "content_type": "image/png",
1601+ "data": "iVBORw0KGgoAAAANSUhEUgAA…"
1602+ },
1603+ "response": {
1604+ "id": "3f9c2a7e5b1d48c6a0e7f4b2d9c81a6e5f3b7d2c9a4e8f1b6c0d5a7e2f9b3c41",
1605+ "name": "preview.png",
1606+ "url": "https://g1tusercontent.com/attachments/3f9c2a7e5b1d48c6a0e7f4b2d9c81a6e5f3b7d2c9a4e8f1b6c0d5a7e2f9b3c41",
1607+ "markdown": "![preview.png](https://g1tusercontent.com/attachments/3f9c2a7e5b1d48c6a0e7f4b2d9c81a6e5f3b7d2c9a4e8f1b6c0d5a7e2f9b3c41)",
1608+ "content_type": "image/png",
1609+ "size": 184213
1610+ },
1611+ "notes": "`data` is the file base64-encoded: `base64 -w0 preview.png`. Paste `markdown` into the `body` of an issue, pull request or comment, or a pull request's `summary`. The type is read from the bytes; a file over 10 MB is refused with `413` (`too_large`), one that is not a PNG, JPEG, GIF or WebP with `415` (`unsupported_type`). From a shell, `g1t attach preview.png` does all of it and prints the markdown."
1612+ },
15931613 "react": {
15941614 "params": {
15951615 "number": 12,
+3−0
10601060 route("PATCH", "/repos/:owner/:name/issues/comments/:comment_id", Op::EditComment, &[]),
10611061 route("DELETE", "/repos/:owner/:name/issues/comments/:comment_id", Op::DeleteComment, &[]),
10621062 route("POST", "/repos/:owner/:name/issues/:number/reactions", Op::React, &[]),
1063+ // An image to show in an issue's, a pull request's or a comment's Markdown.
1064+ route("POST", "/repos/:owner/:name/attachments", Op::UploadAttachment, &[]),
10631065 route(
10641066 "GET",
10651067 "/repos/:owner/:name/pulls",
15941596 // Still an issue's comments, labels and subscription.
15951597 assert_eq!(op("POST", "/repos/acme/web/issues/7/comments"), Op::AddComment);
15961598 assert_eq!(op("POST", "/repos/acme/web/issues/7/reactions"), Op::React);
1599+ assert_eq!(op("POST", "/repos/acme/web/attachments"), Op::UploadAttachment);
15971600 assert_eq!(op("DELETE", "/repos/acme/web/issues/7/labels"), Op::RemoveIssueLabels);
15981601 assert_eq!(op("DELETE", "/repos/acme/web/issues/7/subscription"), Op::DeleteThreadSubscription);
15991602 }
+2−1
149149 Tool {
150150 name: "issue",
151151 title: "Issues",
152− description: "Issues: what should change. Before working on one, `work` gives everything about it (definition of done, who is on it, its pull requests, what it waits on); `claim` says you are on it (advisory and expiring, never a lock), `release` or `handoff` when you stop. `update` with blocked_by sets the issues that must land first. Issues and pull requests share numbers; `comment` works on either.",
152+ description: "Issues: what should change. Before working on one, `work` gives everything about it (definition of done, who is on it, its pull requests, what it waits on); `claim` says you are on it (advisory and expiring, never a lock), `release` or `handoff` when you stop. `update` with blocked_by sets the issues that must land first. Issues and pull requests share numbers; `comment` works on either, and `attach` uploads an image (a screenshot) whose markdown goes in either's body or a comment.",
153153 default_action: None,
154154 actions: &[
155155 a("list", Op::ListIssues, "Issues on a repository, newest first"),
166166 a("edit_comment", Op::EditComment, "Change a comment's text: your own, or any with the Maintain role"),
167167 a("delete_comment", Op::DeleteComment, "Delete a comment: your own, or any with the Maintain role"),
168168 a("react", Op::React, "React with an emoji to it, or with comment_id to one of its comments; remove takes it back"),
169+ a("attach", Op::UploadAttachment, "Upload an image (base64, PNG/JPEG/GIF/WebP, at most 10 MB; keep it small over MCP) for its markdown, to paste into a body, summary or comment"),
169170 a("import", Op::ImportIssue, "Open an issue from a Jira, Linear or Sentry item"),
170171 a("work", Op::Coordination(CoordinationOp::GetWork), "Everything to pick it up: done means, claims, sessions, pull requests, blockers, decisions, recent events"),
171172 a("claim", Op::Coordination(CoordinationOp::ClaimIssue), "Say you are working on it; lists who else is, and what it is blocked by"),
+21−0
234234 `@me` is you. At a terminal, `g1t issue create` without `--title` asks for
235235 one and opens your editor for the body.
236236
237+### Images in descriptions and comments
238+
239+`g1t attach` uploads images, such as a screenshot of a preview, and prints the
240+Markdown that shows each, to paste into a description or a comment:
241+
242+```sh
243+g1t attach preview.png # prints ![preview.png](https://g1tusercontent.com/attachments/…)
244+g1t attach before.png after.png --json # id, name, url, markdown, content_type and size of each
245+g1t pr comment 14 -b "The preview, after the fix:" --attach preview.png
246+g1t pr ready --summary "Names the caller." --attach preview.png
247+```
248+
249+`--attach FILE` (repeat it for more) on `g1t issue create`, `g1t issue comment`,
250+`g1t pr create`, `g1t pr comment` and `g1t pr ready` uploads each file and adds
251+its Markdown after the text. An attachment is a PNG, JPEG, GIF or WebP image of
252+at most 10 MB, judged by its bytes. It is served from g1t's usercontent address,
253+never from g1t.sh, and its address is its permission: anyone given it can see
254+the image, even from a private repository. Never commit an image to the
255+repository just to link it. Through the API it is
256+[`upload_attachment`](/reference/api/issues/upload-attachment/).
257+
237258 ### Pull requests
238259
239260 From a branch with your commits on it:
+22−0
340340 `GET /repos/{owner}/{name}/pulls/{number}`: each with its `content`,
341341 `count` and the `users` who reacted.
342342
343+## Images
344+
345+To show an image in a pull request's or an issue's description, or in a
346+comment (a screenshot of a preview, say), upload it and paste the Markdown
347+you get back. From a shell, `g1t attach preview.png` prints
348+`![preview.png](https://g1tusercontent.com/attachments/…)`, and `--attach
349+preview.png` on `g1t pr create`, `g1t pr ready`, `g1t pr comment`,
350+`g1t issue create` and `g1t issue comment` adds it for you (see the
351+[CLI guide](/guides/cli/#images-in-descriptions-and-comments)). Through the API it is
352+`POST /repos/{owner}/{name}/attachments` with `name`, the file
353+base64-encoded in `data`, and optionally `content_type`; it answers with
354+`id`, `url`, `content_type`, `size` and `markdown`. On the MCP server it is
355+the `issue` tool's `attach` action.
356+
357+An attachment is a PNG, JPEG, GIF or WebP image of at most 10 MB, judged by
358+its bytes: a larger file is refused with `413` (`too_large`), anything else
359+with `415` (`unsupported_type`). Anyone who may comment on the repository
360+may upload one, with `issues:write`. It is served from g1t's usercontent
361+address, never g1t.sh, and its address is its permission: anyone given the
362+link can see the image, even one from a private repository. A purged
363+repository's attachments are deleted with it.
364+
343365 ## Commit comments
344366
345367 You can comment on any commit: on the whole commit, under its changes on
+1−1
156156 | `API_PORT` | `8789` | The port the API and the MCP server are published on |
157157 | `API_URL` | `PUBLIC_URL`'s host on `API_PORT` | The address of the API, as people and applications reach it. It is also the OAuth issuer. Set it when the API is behind a proxy, for example `https://api.git.example.com`. |
158158 | `MCP_URL` | `API_URL/mcp` | The address of the MCP server. |
159−| `USERCONTENT_URL` | `PUBLIC_URL/-/usercontent` | Where [raw files](/guides/git/#raw-files) and avatars are served. Set it to a host of its own (another domain, not a subdomain of `PUBLIC_URL`'s, for example `https://files.example.net`) that your proxy sends to the same port as `PUBLIC_URL`, keeping the `Host` header, so a file in a repository can never reach the site's session cookie. Unset, they are served under `PUBLIC_URL`, still as plain text or images that cannot run script. |
159+| `USERCONTENT_URL` | `PUBLIC_URL/-/usercontent` | Where [raw files](/guides/git/#raw-files), avatars and images attached to issues, pull requests and comments are served (the API names attachments by it too). Set it to a host of its own (another domain, not a subdomain of `PUBLIC_URL`'s, for example `https://files.example.net`) that your proxy sends to the same port as `PUBLIC_URL`, keeping the `Host` header, so a file in a repository can never reach the site's session cookie. Unset, they are served under `PUBLIC_URL`, still as plain text or images that cannot run script. |
160160 | `MAILPIT_PORT` | `8025` | The port of the Mailpit inbox |
161161 | `MAIL_FROM` | `g1t <noreply@localhost>` | The sender of g1t's email |
162162 | `MAIL_URL` | `http://mailpit:8025` | The Mailpit server g1t sends mail through |
+5−1
295295 context`, `g1t work claim`, `g1t pr create`, `g1t pr checks --watch`.
296296 It works on a branch and a pull request, except in a repository with no
297297 commits yet, where there is nothing to open one against: it pushes the
298− first version straight to the default branch.
298+ first version straight to the default branch. To show a screenshot of its
299+ preview in the pull request or a comment, it uploads the image with
300+ [`g1t attach`](/guides/cli/#images-in-descriptions-and-comments) (or
301+ `--attach` on `g1t pr create`, `g1t pr ready` and `g1t pr comment`) and
302+ pastes the Markdown it prints, rather than committing the image.
299303 g1t's MCP server is attached too, for anything the CLI has no command
300304 for. Its sandbox reaches the internet as a CI runner does, and it can
301305 search and read the web (web search and web fetch), for a fact the work
+34−5
295295 | `-t, --title <TITLE>` | |
296296 | `-b, --body <BODY>` | The text. Markdown. |
297297 | `-F, --body-file <FILE>` | Read the text from a file, or - for stdin. |
298+| `--attach <FILE>` | An image to show after the text (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. |
298299 | `-l, --label <LABEL>` | A label; repeat for more. |
299300 | `-a, --assignee <ASSIGNEE>` | Someone to assign; repeat for more (@me for you). |
300301 | `-m, --milestone <MILESTONE>` | A milestone's number. |
301302 | `-w, --web` | Open the new-issue page in the browser instead. |
302303
303−Calls [`create_issue`](/reference/api/issues/create-issue/), [`update_issue`](/reference/api/issues/update-issue/).
304+Calls [`create_issue`](/reference/api/issues/create-issue/), [`update_issue`](/reference/api/issues/update-issue/), [`upload_attachment`](/reference/api/issues/upload-attachment/).
304305
305306 ### g1t issue comment
306307
315316 | `[NUMBER]` | The number; for a pull request, the current branch's when left out. |
316317 | `-b, --body <BODY>` | The text. Markdown. |
317318 | `-F, --body-file <FILE>` | Read the text from a file, or - for stdin. |
319+| `--attach <FILE>` | An image to show after the text (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. |
318320
319−Calls [`add_comment`](/reference/api/issues/add-comment/).
321+Calls [`add_comment`](/reference/api/issues/add-comment/), [`upload_attachment`](/reference/api/issues/upload-attachment/).
320322
321323 ### g1t issue close
322324
425427 | `-t, --title <TITLE>` | |
426428 | `-b, --body <BODY>` | The text. Markdown. |
427429 | `-F, --body-file <FILE>` | Read the text from a file, or - for stdin. |
430+| `--attach <FILE>` | An image to show after the text (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. |
428431 | `-B, --base <BASE>` | The branch to merge into; the default branch when left out. |
429432 | `-H, --head <HEAD>` | The branch with the changes; the current one when left out. |
430433 | `-i, --issue <ISSUE>` | The issue it is for. Without a branch, starts a draft in a working copy of its own. |
433436 | `--no-push` | Do not push the branch first. |
434437 | `--head-repo <OWNER/NAME>` | The fork, OWNER/NAME, that holds the branch. In a clone of your fork (origin) whose upstream is the repository, origin when left out. |
435438
436−Calls [`create_pull_request`](/reference/api/pull-requests/create-pull-request/), [`get_repo`](/reference/api/repositories/get-repo/), [`convert_pull_request_to_draft`](/reference/api/pull-requests/convert-pull-request-to-draft/).
439+Calls [`create_pull_request`](/reference/api/pull-requests/create-pull-request/), [`get_repo`](/reference/api/repositories/get-repo/), [`convert_pull_request_to_draft`](/reference/api/pull-requests/convert-pull-request-to-draft/), [`upload_attachment`](/reference/api/issues/upload-attachment/).
437440
438441 ### g1t pr checkout
439442
558561 | --- | --- |
559562 | `[NUMBER]` | |
560563 | `-s, --summary <SUMMARY>` | What changed and why; it becomes the description. The current one when left out. |
564+| `--attach <FILE>` | An image to show after the summary (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. |
561565 | `--undo` | Turn it back into a draft. |
562566
563−Calls [`mark_pull_request_ready`](/reference/api/pull-requests/mark-pull-request-ready/), [`convert_pull_request_to_draft`](/reference/api/pull-requests/convert-pull-request-to-draft/).
567+Calls [`mark_pull_request_ready`](/reference/api/pull-requests/mark-pull-request-ready/), [`convert_pull_request_to_draft`](/reference/api/pull-requests/convert-pull-request-to-draft/), [`upload_attachment`](/reference/api/issues/upload-attachment/).
564568
565569 ### g1t pr comment
566570
575579 | `[NUMBER]` | The number; for a pull request, the current branch's when left out. |
576580 | `-b, --body <BODY>` | The text. Markdown. |
577581 | `-F, --body-file <FILE>` | Read the text from a file, or - for stdin. |
582+| `--attach <FILE>` | An image to show after the text (PNG, JPEG, GIF or WebP, at most 10 MB): uploaded, and its Markdown added. Repeat for more. |
578583
579−Calls [`add_comment`](/reference/api/issues/add-comment/).
584+Calls [`add_comment`](/reference/api/issues/add-comment/), [`upload_attachment`](/reference/api/issues/upload-attachment/).
580585
581586 ### g1t pr close
582587
13961401
13971402 Calls [`why`](/reference/api/decisions-and-why/why/).
13981403
1404+## g1t attach
1405+
1406+Upload images, such as a screenshot, to show in an issue, a pull request or a comment; prints their Markdown.
1407+
1408+```text
1409+g1t attach [OPTIONS] <FILE>...
1410+```
1411+
1412+| Argument or flag | |
1413+| --- | --- |
1414+| `<FILE>` | The files: PNG, JPEG, GIF or WebP, at most 10 MB each. |
1415+
1416+```text
1417+Prints each image's Markdown, ![name](url), to paste into a description or a comment; --json prints
1418+{ id, name, url, markdown, content_type, size } (a list for several files). The image is served from
1419+g1t's usercontent address, and anyone given that address can see it, even from a private repository.
1420+--attach FILE on g1t issue create, g1t issue comment, g1t pr create, g1t pr comment and g1t pr ready
1421+uploads and adds the Markdown for you:
1422+ g1t attach preview.png
1423+ g1t pr comment --body 'The preview, after the fix:' --attach preview.png
1424+```
1425+
1426+Calls [`upload_attachment`](/reference/api/issues/upload-attachment/).
1427+
13991428 ## g1t api
14001429
14011430 Call any API operation or path, with your token.
+4−1
344344 Issues: what should change. Before working on one, `work` gives everything
345345 about it, and `claim` says you are on it; see
346346 [sessions, claims and dependencies](/guides/sessions/). Issues and pull
347−requests share numbers, so `comment` works on either.
347+requests share numbers, so `comment` works on either. `attach` uploads an
348+image, such as a screenshot, and gives the Markdown that shows it in either's
349+description or a comment.
348350
349351 | Action | What it does | Required | Scope |
350352 | --- | --- | --- | --- |
361363 | [`comment`](/reference/api/issues/add-comment/) | Comment on an issue or a pull request; with `path` and `line`, on one line of a pull request's change. | `repo`, `number`, `body` | `issues:write` |
362364 | [`edit_comment`](/reference/api/issues/edit-comment/) | Change a comment's text, by its id from `get`. Your own, or anyone's with the Maintain role or higher. Notes of what happened cannot be edited. | `repo`, `comment_id`, `body` | `issues:write` |
363365 | [`delete_comment`](/reference/api/issues/delete-comment/) | Delete a comment, by its id. Your own, or anyone's with the Maintain role or higher. A review that approved or requested changes cannot be deleted. | `repo`, `comment_id` | `issues:write` |
366+| [`attach`](/reference/api/issues/upload-attachment/) | Upload an image to show in a description or a comment: `data` base64-encoded, `name` its file name. PNG, JPEG, GIF or WebP of at most 10 MB, by its bytes (`413` `too_large`, `415` `unsupported_type` otherwise). Returns `id`, `url`, `content_type`, `size` and `markdown` (`![name](url)`) to paste into a `body` or `summary`. The file travels in the tool call, so keep it small (a few hundred KB); `g1t attach FILE` sends one from disk. Anyone who may comment on the repository may. | `repo`, `name`, `data` | `issues:write` |
364367 | [`import`](/reference/api/integrations/import-issue/) | Open an issue from a Jira, Linear or Sentry item, linked to it. | `repo`, `reference` | `issues:write` |
365368 | [`work`](/reference/api/claims-and-work/get-work/) | Everything to pick an issue up: its definition of done and required checks, active claims, sessions, pull requests (checks, mergeability, overlaps, queue place), what it is blocked by and blocks, decisions, and its recent coordination events. | `repo`, `number` | `issues:read` |
366369 | [`claim`](/reference/api/claims-and-work/claim-issue/) | Say you are working on an open issue: advisory and expiring (`ttl_minutes`, 30 unless you say, at most 240), never a lock. Returns `others` already on it and what it is `blocked_by`. Claiming again, or a heartbeat of its `session_id`, renews it. With `title` (and `body`) instead of `number`, it [opens the issue first](/reference/api/claims-and-work/claim-new-issue/), claims it, attaches the `session_id` session to it, and returns it as `issue`. | `repo`, and `number` or `title` | `issues:write` |
+0−0

Binary or large file; its contents are not shown.

+1−0
88 * <usercontent>/avatars/<sha256> an uploaded avatar
99 * <usercontent>/emoji/<sha256> a workspace's custom emoji
1010 * <usercontent>/production-images/<sha256> a project's uploaded production image
11+ * <usercontent>/attachments/<id> an image in an issue, pull request or comment
1112 *
1213 * A public repository's files are there for anyone. A private one's carry
1314 * `?token=`, a signature the site makes for someone who can read the
+12−3
1414 export const EMOJI_PATH = /^\/emoji\/([0-9a-f]{64})$/;
1515 /** A project's uploaded production image, by the SHA-256 of its bytes: kept by projects under `production/<hash>` in the same namespace. */
1616 export const PRODUCTION_IMAGE_PATH = /^\/production-images\/([0-9a-f]{64})$/;
17+/**
18+ * An image put in an issue's, a pull request's or a comment's Markdown, by its
19+ * random id (256 bits, so its address is its permission): kept by work under
20+ * `attachments/<id>` in the same namespace (services/work/src/attachments.rs).
21+ */
22+export const ATTACHMENT_PATH = /^\/attachments\/([0-9a-f]{64})$/;
1723 /** A file put in a Docs page, by its random key: kept by the artifacts service (services/artifacts). */
1824 export const DOCS_FILE_PATH = /^\/docs-files\/([0-9a-f]{64})$/;
1925 /** The only types identity stores, having checked each image's bytes. */
4652 if (emoji) return serveAvatar(env, ctx, method, `emoji/${emoji[1]!}`, new URL(request.url).origin);
4753 const production = PRODUCTION_IMAGE_PATH.exec(path);
4854 if (production) return serveAvatar(env, ctx, method, `production/${production[1]!}`, new URL(request.url).origin);
55+ const attachment = ATTACHMENT_PATH.exec(path);
56+ if (attachment) return serveAvatar(env, ctx, method, `attachments/${attachment[1]!}`, new URL(request.url).origin);
4957 const docsFile = DOCS_FILE_PATH.exec(path);
5058 if (docsFile) return serveDocsFile(env, method, docsFile[1]!);
5159 const file = parseRawPath(path);
8896 }
8997
9098 /**
91− * An uploaded avatar, a custom emoji (`key` is then `emoji/<hash>`) or a
92− * production image (`production/<hash>`). Its
99+ * An uploaded avatar, a custom emoji (`key` is then `emoji/<hash>`), a
100+ * production image (`production/<hash>`) or an attachment
101+ * (`attachments/<id>`, at the same path). Its
93102 * address is its hash, so it never changes and is
94103 * kept for good: each data centre keeps it in its cache after the first
95104 * view, and storage is read about once per place, not once per visitor.
99108 async function serveAvatar(env: Env, ctx: ExecutionContext, method: string, key: string, origin: string): Promise<Response> {
100109 // The Workers runtime's own cache, which the DOM types do not know.
101110 const cache = (caches as unknown as { default: Cache }).default;
102− const path = key.startsWith("emoji/") ? key : key.startsWith("production/") ? `production-images/${key.slice("production/".length)}` : `avatars/${key}`;
111+ const path = key.startsWith("emoji/") || key.startsWith("attachments/") ? key : key.startsWith("production/") ? `production-images/${key.slice("production/".length)}` : `avatars/${key}`;
103112 const cacheKey = new Request(`${origin}/${path}`, { method: "GET" });
104113 const cached = await cache.match(cacheKey);
105114 if (cached) {
+168−0
1+//! Files put in an issue's, a pull request's or a comment's Markdown: a
2+//! screenshot of a preview, a recording's still. The work service keeps
3+//! them (`upload_attachment`), with whoever may comment on the repository
4+//! allowed to add one, and the usercontent origin serves each at
5+//! `<usercontent>/attachments/<id>` (apps/web/workers/usercontent.ts),
6+//! never the site. An id is 256 random bits, so its address is its
7+//! permission, as a Docs file's is: a private repository's attachment is
8+//! seen by whoever is given its address, as its Markdown gives it.
9+
10+use serde::{Deserialize, Serialize};
11+
12+use crate::User;
13+use crate::repos::RepoPath;
14+
15+/// The largest attachment, in bytes: 10 MB.
16+pub const MAX_ATTACHMENT_BYTES: usize = 10 * 1024 * 1024;
17+
18+/// What an attachment may be, as its bytes show it: PNG, JPEG, GIF or WebP.
19+pub const ATTACHMENT_TYPES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
20+
21+/// The longest name kept, in characters.
22+pub const MAX_ATTACHMENT_NAME_CHARS: usize = 200;
23+
24+/// Where attachments are kept in the usercontent namespace (`AVATARS`):
25+/// `attachments/<id>`, with [`AttachmentMeta`].
26+pub const ATTACHMENT_KEY_PREFIX: &str = "attachments/";
27+
28+/// `upload_attachment`: keep a file to show in Markdown on `repo`, for
29+/// someone who may comment there. `data` is the file, base64-encoded; its
30+/// type is read from its bytes, and a `content_type` that says otherwise
31+/// is refused. Returns `Outcome<Attachment>`: too large is
32+/// `FailureCode::TooLarge`, another type `FailureCode::UnsupportedType`.
33+#[derive(Debug, Serialize, Deserialize)]
34+pub struct UploadAttachmentArgs {
35+ pub actor: User,
36+ pub repo: RepoPath,
37+ /// The file's name, such as `preview.png`: the image's alt text.
38+ pub name: String,
39+ #[serde(default, skip_serializing_if = "Option::is_none")]
40+ pub content_type: Option<String>,
41+ pub data: String,
42+}
43+
44+/// An attachment kept: `path` is under the usercontent origin.
45+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46+pub struct Attachment {
47+ pub id: String,
48+ pub name: String,
49+ pub content_type: String,
50+ pub size: u64,
51+ /// `/attachments/<id>`.
52+ pub path: String,
53+}
54+
55+/// What is kept with an attachment's bytes, as KV metadata (at most 1 KB).
56+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
57+#[serde(rename_all = "camelCase")]
58+pub struct AttachmentMeta {
59+ /// As the usercontent origin serves it.
60+ pub content_type: String,
61+ pub name: String,
62+ pub repo_id: String,
63+ pub user_id: String,
64+ pub size: u64,
65+ /// Milliseconds since the epoch.
66+ pub at: u64,
67+}
68+
69+/// `/attachments/<id>`: where an attachment is under the usercontent origin.
70+pub fn attachment_path(id: &str) -> String {
71+ format!("/attachments/{id}")
72+}
73+
74+/// Whether `id` is an attachment's: 64 lowercase hex digits.
75+pub fn is_attachment_id(id: &str) -> bool {
76+ id.len() == 64 && id.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
77+}
78+
79+/// The type a file's first bytes show it to be, if it is one an attachment may be.
80+pub fn sniff_attachment(bytes: &[u8]) -> Option<&'static str> {
81+ if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
82+ Some("image/png")
83+ } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
84+ Some("image/jpeg")
85+ } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
86+ Some("image/gif")
87+ } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
88+ Some("image/webp")
89+ } else {
90+ None
91+ }
92+}
93+
94+/// A content type as given (`image/PNG; charset=…`), as one of
95+/// [`ATTACHMENT_TYPES`] compares it; `image/jpg` is `image/jpeg`.
96+pub fn normalize_content_type(given: &str) -> String {
97+ let kind = given.split(';').next().unwrap_or_default().trim().to_ascii_lowercase();
98+ if kind == "image/jpg" { "image/jpeg".to_owned() } else { kind }
99+}
100+
101+/// A file's name as kept: its last path segment, without control
102+/// characters, at most [`MAX_ATTACHMENT_NAME_CHARS`]; `image` when nothing is left.
103+pub fn attachment_name(given: &str) -> String {
104+ let last = given.rsplit(['/', '\\']).next().unwrap_or_default();
105+ let name: String = last.chars().filter(|c| !c.is_control()).take(MAX_ATTACHMENT_NAME_CHARS).collect();
106+ let name = name.trim();
107+ if name.is_empty() { "image".to_owned() } else { name.to_owned() }
108+}
109+
110+/// The Markdown that shows the attachment at `url`: `![name](url)`, with
111+/// the brackets and backslashes of its name escaped.
112+pub fn attachment_markdown(name: &str, url: &str) -> String {
113+ let mut alt = String::with_capacity(name.len());
114+ for c in name.chars() {
115+ if matches!(c, '[' | ']' | '\\') {
116+ alt.push('\\');
117+ }
118+ alt.push(c);
119+ }
120+ format!("![{alt}]({url})")
121+}
122+
123+#[cfg(test)]
124+mod tests {
125+ use super::*;
126+
127+ #[test]
128+ fn types_are_read_from_bytes() {
129+ assert_eq!(sniff_attachment(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0]), Some("image/png"));
130+ assert_eq!(sniff_attachment(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpeg"));
131+ assert_eq!(sniff_attachment(b"GIF89a...."), Some("image/gif"));
132+ assert_eq!(sniff_attachment(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp"));
133+ assert_eq!(sniff_attachment(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"), None);
134+ assert_eq!(sniff_attachment(b"%PDF-1.7"), None);
135+ assert_eq!(sniff_attachment(&[]), None);
136+ for kind in ATTACHMENT_TYPES {
137+ assert!(kind.starts_with("image/"));
138+ }
139+ }
140+
141+ #[test]
142+ fn names_and_markdown() {
143+ assert_eq!(attachment_name("shots/preview.png"), "preview.png");
144+ assert_eq!(attachment_name("C:\\tmp\\a b.png"), "a b.png");
145+ assert_eq!(attachment_name(" \n"), "image");
146+ assert_eq!(attachment_name(&"x".repeat(500)).chars().count(), MAX_ATTACHMENT_NAME_CHARS);
147+ assert_eq!(attachment_markdown("preview.png", "https://g1tusercontent.com/attachments/ab"), "![preview.png](https://g1tusercontent.com/attachments/ab)");
148+ assert_eq!(attachment_markdown("a]b[c\\", "u"), "![a\\]b\\[c\\\\](u)");
149+ assert_eq!(normalize_content_type("Image/JPG; q=1"), "image/jpeg");
150+ assert_eq!(attachment_path("ab"), "/attachments/ab");
151+ assert!(is_attachment_id(&"0a".repeat(32)));
152+ assert!(!is_attachment_id(&"0A".repeat(32)));
153+ assert!(!is_attachment_id("abc"));
154+ }
155+
156+ #[test]
157+ fn meta_fits_in_kv_metadata() {
158+ let meta = AttachmentMeta {
159+ content_type: "image/webp".into(),
160+ name: "é".repeat(MAX_ATTACHMENT_NAME_CHARS),
161+ repo_id: "repo_01m4z09g22e7ysqpg2ha423cws".into(),
162+ user_id: "usr_01m4z09g22e7ysqpg2ha423cws".into(),
163+ size: MAX_ATTACHMENT_BYTES as u64,
164+ at: 1_792_627_200_000,
165+ };
166+ assert!(serde_json::to_string(&meta).unwrap().len() <= 1024);
167+ }
168+}
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.