Work: work items as a module of their own, with their own attachments
services/work/src/items/ is the boundary: numbering, links, landing, references, labels, milestones and the migration live there, and the pull request, merge, push and coordination code reach them only through its functions and read fragments, which the compiler holds them to. A later split into a service of its own turns those into RPC calls. Attachments belong to a work item when they are added to one, so an item filed in the workspace itself can take files: a new upload_work_item_attachment operation, the work_item tool's attach, and g1t work comment and work new --attach. An old issue link now resolves for a member who cannot read its repository, and legacy_item_map says when the move is finished.
- G1T-Session:
- ses_01m56adwzvenhby748vvbn96c5
| 381 | 381 | Op::EditWorkItemComment, | |
| 382 | 382 | Op::DeleteWorkItemComment, | |
| 383 | 383 | Op::ReactToWorkItem, | |
| 384 | + | Op::UploadWorkItemAttachment, | |
| 384 | 385 | Op::UploadAttachment, | |
| 385 | 386 | Op::ListWorkItemLabels, | |
| 386 | 387 | Op::AddWorkItemLabels, | |
| ⋯ | |||
| 768 | 769 | Op::DeleteWorkItemComment => "Delete a work item comment", | |
| 769 | 770 | Op::ReactToWorkItem => "React to a work item", | |
| 770 | 771 | Op::ListWorkItemDecisions => "List a work item's decisions", | |
| 772 | + | Op::UploadWorkItemAttachment => "Upload an attachment to a work item", | |
| 771 | 773 | Op::UploadAttachment => "Upload an attachment", | |
| 772 | 774 | Op::ReviewPullRequest => "Review a pull request", | |
| 773 | 775 | Op::ListPullRequests => "List pull requests", | |
| ⋯ | |||
| 1154 | 1156 | if op != Op::Whoami { | |
| 1155 | 1157 | responses.insert("422".into(), error_response("The input is not valid.")); | |
| 1156 | 1158 | } | |
| 1157 | − | if op == Op::UploadAttachment { | |
| 1159 | + | if op == Op::UploadAttachment || op == Op::UploadWorkItemAttachment { | |
| 1158 | 1160 | responses.insert("413".into(), error_response("The file is over 10 MB (`too_large`).")); | |
| 1159 | 1161 | 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`).")); | |
| 1160 | 1162 | } | |
| 202 | 202 | DeleteWorkItemComment, | |
| 203 | 203 | ReactToWorkItem, | |
| 204 | 204 | ListWorkItemDecisions, | |
| 205 | + | UploadWorkItemAttachment, | |
| 205 | 206 | UploadAttachment, | |
| 206 | 207 | ReviewPullRequest, | |
| 207 | 208 | ListPullRequests, | |
| ⋯ | |||
| 867 | 868 | } | |
| 868 | 869 | ||
| 869 | 870 | impl Op { | |
| 870 | − | pub const ALL: [Op; 427] = [ | |
| 871 | + | pub const ALL: [Op; 428] = [ | |
| 871 | 872 | Op::Whoami, | |
| 872 | 873 | Op::GetWorkspace, | |
| 873 | 874 | Op::CreateWorkspace, | |
| ⋯ | |||
| 952 | 953 | Op::DeleteWorkItemComment, | |
| 953 | 954 | Op::ReactToWorkItem, | |
| 954 | 955 | Op::ListWorkItemDecisions, | |
| 956 | + | Op::UploadWorkItemAttachment, | |
| 955 | 957 | Op::UploadAttachment, | |
| 956 | 958 | Op::ReviewPullRequest, | |
| 957 | 959 | Op::ListPullRequests, | |
| ⋯ | |||
| 1388 | 1390 | Op::DeleteWorkItemComment => "delete_work_item_comment", | |
| 1389 | 1391 | Op::ReactToWorkItem => "react_to_work_item", | |
| 1390 | 1392 | Op::ListWorkItemDecisions => "list_work_item_decisions", | |
| 1393 | + | Op::UploadWorkItemAttachment => "upload_work_item_attachment", | |
| 1391 | 1394 | Op::UploadAttachment => "upload_attachment", | |
| 1392 | 1395 | Op::ReviewPullRequest => "review_pull_request", | |
| 1393 | 1396 | Op::ListPullRequests => "list_pull_requests", | |
| ⋯ | |||
| 1789 | 1792 | Op::ListWorkItemDecisions => { | |
| 1790 | 1793 | "The decision records on a work item and on its pull requests, oldest first: each with its kind (proposal, claim, integration_failure, integration_passed, fix, approval, merge or note), its confidence (claimed, tested or approved), who recorded it (absent for g1t), its summary and its evidence. The workspace is `workspace`, its slug; with only `repo`, the repository's workspace." | |
| 1791 | 1794 | } | |
| 1795 | + | Op::UploadWorkItemAttachment => { | |
| 1796 | + | "Upload an image to show in a work item's description or in a comment on it, kept by the work item: one filed with no repository takes files too. As upload_attachment otherwise (PNG, JPEG, GIF and WebP of at most 10 MB, 413 too_large, 415 unsupported_type, the same answer with its markdown); anyone who may comment on the work item may. The workspace is `workspace`, its slug; with only `repo`, the repository's workspace." | |
| 1797 | + | } | |
| 1792 | 1798 | Op::UploadAttachment => { | |
| 1793 | 1799 | "Upload an image to show in a work item'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 (``) to paste into the body or summary of open_work_item, update_work_item, add_work_item_comment, 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." | |
| 1794 | 1800 | } | |
| ⋯ | |||
| 2960 | 2966 | })), | |
| 2961 | 2967 | &["number", "content"], | |
| 2962 | 2968 | ), | |
| 2969 | + | Op::UploadWorkItemAttachment => object( | |
| 2970 | + | item_numbered(json!({ | |
| 2971 | + | "name": { | |
| 2972 | + | "type": "string", | |
| 2973 | + | "description": "The file's name, such as preview.png: the image's alt text in the markdown.", | |
| 2974 | + | }, | |
| 2975 | + | "content_type": { | |
| 2976 | + | "type": "string", | |
| 2977 | + | "enum": g1t_contracts::attachments::ATTACHMENT_TYPES, | |
| 2978 | + | "description": "What the file is. Optional: it is read from the bytes, and refused when it says otherwise.", | |
| 2979 | + | }, | |
| 2980 | + | "data": { | |
| 2981 | + | "type": "string", | |
| 2982 | + | "contentEncoding": "base64", | |
| 2983 | + | "description": "The file, base64-encoded: PNG, JPEG, GIF or WebP of at most 10 MB.", | |
| 2984 | + | }, | |
| 2985 | + | })), | |
| 2986 | + | &["number", "name", "data"], | |
| 2987 | + | ), | |
| 2963 | 2988 | Op::UploadAttachment => object( | |
| 2964 | 2989 | json!({ | |
| 2965 | 2990 | "repo": repo_schema(), | |
| ⋯ | |||
| 4241 | 4266 | | Op::EditWorkItemComment | |
| 4242 | 4267 | | Op::DeleteWorkItemComment | |
| 4243 | 4268 | | Op::ReactToWorkItem | |
| 4269 | + | | Op::UploadWorkItemAttachment | |
| 4244 | 4270 | | Op::ImportWorkItems | |
| 4245 | 4271 | | Op::ListLabels | |
| 4246 | 4272 | | Op::CreateLabel | |
| ⋯ | |||
| 5524 | 5550 | ) | |
| 5525 | 5551 | .await | |
| 5526 | 5552 | } | |
| 5553 | + | Op::UploadWorkItemAttachment => { | |
| 5554 | + | let asked = g1t_contracts::attachments::UploadAttachmentArgs { | |
| 5555 | + | actor: actor(), | |
| 5556 | + | repo: None, | |
| 5557 | + | workspace: item_ws(), | |
| 5558 | + | number: Some(number), | |
| 5559 | + | name: text(input, "name"), | |
| 5560 | + | content_type: optional_text(input, "content_type"), | |
| 5561 | + | data: text(input, "data"), | |
| 5562 | + | }; | |
| 5563 | + | if asked.data.trim().is_empty() { | |
| 5564 | + | return failed(FailureCode::Invalid, "Give data: the file, base64-encoded."); | |
| 5565 | + | } | |
| 5566 | + | let kept: Outcome<g1t_contracts::attachments::Attachment> = call(work, "upload_attachment", &asked).await?; | |
| 5567 | + | match kept { | |
| 5568 | + | Outcome::Ok(attachment) => ok(&attachment_answer(&services.addresses.usercontent, &attachment)), | |
| 5569 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 5570 | + | } | |
| 5571 | + | } | |
| 5527 | 5572 | Op::UpdatePullRequest => { | |
| 5528 | 5573 | // `state` reopens a closed pull request (first, so that the | |
| 5529 | 5574 | // rest can change it) or closes an open one (last). | |
| ⋯ | |||
| 5748 | 5793 | Op::UploadAttachment => { | |
| 5749 | 5794 | let asked = g1t_contracts::attachments::UploadAttachmentArgs { | |
| 5750 | 5795 | actor: actor(), | |
| 5751 | − | repo, | |
| 5796 | + | repo: Some(repo), | |
| 5797 | + | workspace: String::new(), | |
| 5798 | + | number: None, | |
| 5752 | 5799 | name: text(input, "name"), | |
| 5753 | 5800 | content_type: optional_text(input, "content_type"), | |
| 5754 | 5801 | data: text(input, "data"), | |
| 1688 | 1688 | }, | |
| 1689 | 1689 | "notes": "`data` is the file base64-encoded: `base64 -w0 preview.png`. Paste `markdown` into the `body` of a work item, 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." | |
| 1690 | 1690 | }, | |
| 1691 | + | "upload_work_item_attachment": { | |
| 1692 | + | "params": { | |
| 1693 | + | "workspace": "flagon-io", | |
| 1694 | + | "number": 214 | |
| 1695 | + | }, | |
| 1696 | + | "request": { | |
| 1697 | + | "name": "preview.png", | |
| 1698 | + | "content_type": "image/png", | |
| 1699 | + | "data": "iVBORw0KGgoAAAANSUhEUgAA…" | |
| 1700 | + | }, | |
| 1701 | + | "response": { | |
| 1702 | + | "id": "8d2e6b4f1a9c3e7d5b0f2a8c6e4d1b9f7a3c5e0d2b8f6a4c1e9d7b3f5a0c2e8d", | |
| 1703 | + | "name": "preview.png", | |
| 1704 | + | "url": "https://g1tusercontent.com/attachments/8d2e6b4f1a9c3e7d5b0f2a8c6e4d1b9f7a3c5e0d2b8f6a4c1e9d7b3f5a0c2e8d", | |
| 1705 | + | "markdown": "", | |
| 1706 | + | "content_type": "image/png", | |
| 1707 | + | "size": 184213 | |
| 1708 | + | }, | |
| 1709 | + | "notes": "As upload_attachment, kept by the work item: one filed with no repository takes files too. Paste `markdown` into the work item's `body` or a comment on it. From a shell, `g1t work comment 214 --attach preview.png` uploads it and comments." | |
| 1710 | + | }, | |
| 1691 | 1711 | "react": { | |
| 1692 | 1712 | "params": { | |
| 1693 | 1713 | "number": 12, |
| 623 | 623 | route("DELETE", "/workspaces/:workspace/work/:number/labels/:label", Op::RemoveWorkItemLabels, &[]), | |
| 624 | 624 | route("POST", "/workspaces/:workspace/work/:number/comments", Op::AddWorkItemComment, &[]), | |
| 625 | 625 | route("POST", "/workspaces/:workspace/work/:number/reactions", Op::ReactToWorkItem, &[]), | |
| 626 | + | route("POST", "/workspaces/:workspace/work/:number/attachments", Op::UploadWorkItemAttachment, &[]), | |
| 626 | 627 | route("GET", "/workspaces/:workspace/work/:number/decisions", Op::ListWorkItemDecisions, &[]), | |
| 627 | 628 | // The work items linked to a repository: its Work tab. Read only. | |
| 628 | 629 | route("GET", "/repos/:owner/:name/work", Op::ListRepoWorkItems, &[("state", "state"), ("label", "label"), ("milestone", "milestone"), ("assignee", "assignee"), ("role", "role")]), |
| 162 | 162 | a("delete_comment", Op::DeleteWorkItemComment, "Delete a comment: your own, or any with the Maintain role"), | |
| 163 | 163 | a("react", Op::ReactToWorkItem, "React with an emoji to it, or with comment_id to one of its comments; remove takes it back"), | |
| 164 | 164 | a("decisions", Op::ListWorkItemDecisions, "Decision records on it and its pull requests"), | |
| 165 | − | 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"), | |
| 165 | + | a("attach", Op::UploadWorkItemAttachment, "Upload an image (base64, PNG/JPEG/GIF/WebP, at most 10 MB; keep it small over MCP) for its markdown, to paste into its body or a comment"), | |
| 166 | 166 | a("import", Op::ImportWorkItems, "Open a work item from a Jira, Linear or Sentry item"), | |
| 167 | 167 | a("resolve", Op::ResolveRef, "What #N names in a workspace, or in repo: a work item or a pull request"), | |
| 168 | 168 | a("work", Op::Coordination(CoordinationOp::GetWork), "Everything to pick it up: done means, repositories and what landed, missing targets, suggested targets, claims, sessions, pull requests, blockers, decisions"), | |
| ⋯ | |||
| 191 | 191 | a("edit_comment", Op::EditComment, "Change a comment's text: your own, or any with the Maintain role"), | |
| 192 | 192 | a("delete_comment", Op::DeleteComment, "Delete a comment: your own, or any with the Maintain role"), | |
| 193 | 193 | a("react", Op::React, "React with an emoji to it, or with comment_id to one of its comments; remove takes it back"), | |
| 194 | + | 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 its body, its summary or a comment"), | |
| 194 | 195 | a("ready", Op::MarkPullRequestReady, "Mark a draft ready, with a summary"), | |
| 195 | 196 | a("draft", Op::ConvertPullRequestToDraft, "Turn it back into a draft"), | |
| 196 | 197 | a("request_reviewers", Op::RequestReviewers, "Ask people or teams to review it"), | |
| ⋯ | |||
| 1046 | 1047 | // Labels and milestones are the workspace's, managed with work:write; | |
| 1047 | 1048 | // looking up the ticket a work item refers to is search's, and a | |
| 1048 | 1049 | // repository's commit comments and its work list are the | |
| 1049 | − | // repository's. | |
| 1050 | − | assert_eq!(names, vec![json!("search"), json!("repository"), json!("work_item"), json!("workspace"), json!("account")]); | |
| 1050 | + | // repository's; an image for a pull request is its attach. | |
| 1051 | + | assert_eq!(names, vec![json!("search"), json!("repository"), json!("work_item"), json!("pull_request"), json!("workspace"), json!("account")]); | |
| 1051 | 1052 | // Notifications are a resource of their own: reading them lists | |
| 1052 | 1053 | // only what reads. | |
| 1053 | 1054 | let reader = token(Some(vec![Scope::NotificationsRead])); | |
Binary or large file; its contents are not shown.
| 1 | − | //! Files put in an issue's, a pull request's or a comment's Markdown: a | |
| 1 | + | //! Files put in a work item's, a pull request's or a comment's Markdown: a | |
| 2 | 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 | |
| 3 | + | //! them (`upload_attachment`), with whoever may comment on the repository, | |
| 4 | + | //! or on the work item, allowed to add one, and the usercontent origin serves each at | |
| 5 | 5 | //! `<usercontent>/attachments/<id>` (apps/web/workers/usercontent.ts), | |
| 6 | 6 | //! never the site. An id is 256 random bits, so its address is its | |
| 7 | 7 | //! permission, as a Docs file's is: a private repository's attachment is | |
| ⋯ | |||
| 25 | 25 | /// `attachments/<id>`, with [`AttachmentMeta`]. | |
| 26 | 26 | pub const ATTACHMENT_KEY_PREFIX: &str = "attachments/"; | |
| 27 | 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 | |
| 28 | + | /// `upload_attachment`: keep a file to show in Markdown on `repo`, or | |
| 29 | + | /// without one on work item `number` of `workspace` (one filed with no | |
| 30 | + | /// repository takes files too), for someone who may comment there. `data` | |
| 31 | + | /// is the file, base64-encoded; its | |
| 30 | 32 | /// type is read from its bytes, and a `content_type` that says otherwise | |
| 31 | 33 | /// is refused. Returns `Outcome<Attachment>`: too large is | |
| 32 | 34 | /// `FailureCode::TooLarge`, another type `FailureCode::UnsupportedType`. | |
| 33 | 35 | #[derive(Debug, Serialize, Deserialize)] | |
| 34 | 36 | pub struct UploadAttachmentArgs { | |
| 35 | 37 | pub actor: User, | |
| 36 | − | pub repo: RepoPath, | |
| 38 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 39 | + | pub repo: Option<RepoPath>, | |
| 40 | + | /// With `number`, the work item it is for, when `repo` is not given. | |
| 41 | + | #[serde(default, skip_serializing_if = "String::is_empty")] | |
| 42 | + | pub workspace: String, | |
| 43 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 44 | + | pub number: Option<u32>, | |
| 37 | 45 | /// The file's name, such as `preview.png`: the image's alt text. | |
| 38 | 46 | pub name: String, | |
| 39 | 47 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| ⋯ | |||
| 59 | 67 | /// As the usercontent origin serves it. | |
| 60 | 68 | pub content_type: String, | |
| 61 | 69 | pub name: String, | |
| 62 | − | pub repo_id: String, | |
| 70 | + | /// The repository it was put in, or (`item_id`) the work item. | |
| 71 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 72 | + | pub repo_id: Option<String>, | |
| 73 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 74 | + | pub item_id: Option<String>, | |
| 63 | 75 | pub user_id: String, | |
| 64 | 76 | pub size: u64, | |
| 65 | 77 | /// Milliseconds since the epoch. | |
| ⋯ | |||
| 158 | 170 | let meta = AttachmentMeta { | |
| 159 | 171 | content_type: "image/webp".into(), | |
| 160 | 172 | name: "é".repeat(MAX_ATTACHMENT_NAME_CHARS), | |
| 161 | − | repo_id: "repo_01m4z09g22e7ysqpg2ha423cws".into(), | |
| 173 | + | repo_id: Some("repo_01m4z09g22e7ysqpg2ha423cws".into()), | |
| 174 | + | item_id: None, | |
| 162 | 175 | user_id: "usr_01m4z09g22e7ysqpg2ha423cws".into(), | |
| 163 | 176 | size: MAX_ATTACHMENT_BYTES as u64, | |
| 164 | 177 | at: 1_792_627_200_000, | |
| 1175 | 1175 | ("react_to_work_item", Scope::WorkWrite), | |
| 1176 | 1176 | // An image for a description or a comment: whoever may comment. | |
| 1177 | 1177 | ("upload_attachment", Scope::WorkWrite), | |
| 1178 | + | ("upload_work_item_attachment", Scope::WorkWrite), | |
| 1178 | 1179 | ("create_commit_comment", Scope::WorkWrite), | |
| 1179 | 1180 | ("update_commit_comment", Scope::WorkWrite), | |
| 1180 | 1181 | ("delete_commit_comment", Scope::WorkWrite), |
| 439 | 439 | pub items: Vec<MigratedItem>, | |
| 440 | 440 | /// Where the next page starts; null on the last. | |
| 441 | 441 | pub next: Option<String>, | |
| 442 | + | /// Whether every issue has its work item's number: until then the map | |
| 443 | + | /// is not whole, and a service catching up on it waits. | |
| 444 | + | #[serde(default)] | |
| 445 | + | pub moved: bool, | |
| 442 | 446 | } | |
| 443 | 447 | ||
| 444 | 448 | #[cfg(test)] |
| 51 | 51 | ||
| 52 | 52 | /// Uploads one file to `repo`: `{ id, name, url, markdown, content_type, size }`. | |
| 53 | 53 | pub fn upload(client: &Client, repo: &Repo, path: &Path) -> Result<Value> { | |
| 54 | + | let (name, kind, data) = read(path)?; | |
| 55 | + | client.op("upload_attachment").repo(repo).field("name", name).field_opt("content_type", kind).field("data", data).send() | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Uploads one file to work item `number` of `workspace`, which keeps it: | |
| 59 | + | /// one filed with no repository takes files too. | |
| 60 | + | pub fn upload_to_item(client: &Client, workspace: &str, number: u64, path: &Path) -> Result<Value> { | |
| 61 | + | let (name, kind, data) = read(path)?; | |
| 62 | + | client | |
| 63 | + | .op("upload_work_item_attachment") | |
| 64 | + | .arg("workspace", workspace) | |
| 65 | + | .arg("number", number) | |
| 66 | + | .field("name", name) | |
| 67 | + | .field_opt("content_type", kind) | |
| 68 | + | .field("data", data) | |
| 69 | + | .send() | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// A file's name, the type its name says and its bytes in base64, once it | |
| 73 | + | /// is known to be small enough. | |
| 74 | + | fn read(path: &Path) -> Result<(String, Option<&'static str>, String)> { | |
| 54 | 75 | let shown = path.display(); | |
| 55 | 76 | let size = std::fs::metadata(path) | |
| 56 | 77 | .map_err(|error| Error::usage(format!("{shown}: {error}")))? | |
| ⋯ | |||
| 68 | 89 | .and_then(|name| name.to_str()) | |
| 69 | 90 | .unwrap_or("image") | |
| 70 | 91 | .to_owned(); | |
| 71 | − | client | |
| 72 | − | .op("upload_attachment") | |
| 73 | − | .repo(repo) | |
| 74 | − | .field("name", name) | |
| 75 | − | .field_opt("content_type", content_type(path)) | |
| 76 | − | .field("data", base64::engine::general_purpose::STANDARD.encode(bytes)) | |
| 77 | − | .send() | |
| 92 | + | Ok((name, content_type(path), base64::engine::general_purpose::STANDARD.encode(bytes))) | |
| 78 | 93 | } | |
| 79 | 94 | ||
| 80 | 95 | /// `body` with the Markdown of each of `files` after it, uploaded to | |
| ⋯ | |||
| 91 | 106 | Ok(Some(appended(body.as_deref(), &markdown))) | |
| 92 | 107 | } | |
| 93 | 108 | ||
| 109 | + | /// As [`with_attachments`], with the files kept by work item `number` of | |
| 110 | + | /// `workspace`. | |
| 111 | + | pub fn with_item_attachments(client: &Client, workspace: &str, number: u64, body: Option<String>, files: &[PathBuf]) -> Result<Option<String>> { | |
| 112 | + | if files.is_empty() { | |
| 113 | + | return Ok(body); | |
| 114 | + | } | |
| 115 | + | let mut markdown = Vec::with_capacity(files.len()); | |
| 116 | + | for file in files { | |
| 117 | + | let uploaded = upload_to_item(client, workspace, number, file)?; | |
| 118 | + | markdown.push(s(&uploaded, "markdown").to_owned()); | |
| 119 | + | } | |
| 120 | + | Ok(Some(appended(body.as_deref(), &markdown))) | |
| 121 | + | } | |
| 122 | + | ||
| 94 | 123 | /// `body`, then a blank line, then each image's Markdown on a line of its own. | |
| 95 | 124 | pub fn appended(body: Option<&str>, markdown: &[String]) -> String { | |
| 96 | 125 | let images = markdown.join("\n"); | |
| 263 | 263 | args.body.body_file.as_deref(), | |
| 264 | 264 | args.body.body_file.is_none() && args.attach.is_empty() && prompt::interactive() && !ctx.out.structured(), | |
| 265 | 265 | )?; | |
| 266 | − | let body = if args.attach.is_empty() { | |
| 267 | − | body | |
| 268 | − | } else { | |
| 269 | − | super::attach::with_attachments(&place.client, place.here("--attach")?, body, &args.attach)? | |
| 266 | + | // Images go with a repository it is filed from; without one, the work | |
| 267 | + | // item keeps them, once it is open. | |
| 268 | + | let (body, later) = match &place.repo { | |
| 269 | + | Some(repo) if !args.attach.is_empty() => (super::attach::with_attachments(&place.client, repo, body, &args.attach)?, &[][..]), | |
| 270 | + | _ => (body, &args.attach[..]), | |
| 270 | 271 | }; | |
| 271 | 272 | let labels: Vec<Value> = args.label.iter().map(|l| Value::String(l.clone())).collect(); | |
| 272 | 273 | let targets: Vec<Value> = args.target.iter().map(|t| Value::String(t.trim().to_owned())).collect(); | |
| ⋯ | |||
| 280 | 281 | .field_opt("milestone", args.milestone) | |
| 281 | 282 | .send()?; | |
| 282 | 283 | let number = made["number"].as_u64().unwrap_or_default(); | |
| 284 | + | let made = if later.is_empty() { | |
| 285 | + | made | |
| 286 | + | } else { | |
| 287 | + | let body = super::attach::with_item_attachments(&place.client, &place.workspace, number, made["body"].as_str().map(str::to_owned), later)?; | |
| 288 | + | place.op("update_work_item").arg("number", number).field_opt("body", body).send()? | |
| 289 | + | }; | |
| 283 | 290 | let made = if args.assignee.is_empty() { | |
| 284 | 291 | made | |
| 285 | 292 | } else { | |
| ⋯ | |||
| 297 | 304 | let place = place(ctx, workspace)?; | |
| 298 | 305 | // Images alone make a comment; without any, the text is asked for. | |
| 299 | 306 | let body = prompt::body(args.body.body, args.body.body_file.as_deref(), args.attach.is_empty())?; | |
| 300 | − | let body = if args.attach.is_empty() { | |
| 301 | − | body | |
| 302 | − | } else { | |
| 303 | − | super::attach::with_attachments(&place.client, place.here("--attach")?, body, &args.attach)? | |
| 304 | − | }; | |
| 307 | + | let body = super::attach::with_item_attachments(&place.client, &place.workspace, args.number, body, &args.attach)?; | |
| 305 | 308 | let body = body | |
| 306 | 309 | .filter(|b| !b.trim().is_empty()) | |
| 307 | 310 | .ok_or_else(|| prompt::needed("The comment", "--body, --body-file or --attach"))?; | |
| 262 | 262 | Mapping { command: "session touch", ops: &["record_session_entries"] }, | |
| 263 | 263 | Mapping { command: "work list", ops: &["list_work_items"] }, | |
| 264 | 264 | Mapping { command: "work view", ops: &["get_work", "get_work_item"] }, | |
| 265 | − | Mapping { command: "work new", ops: &["open_work_item", "update_work_item", "upload_attachment"] }, | |
| 266 | − | Mapping { command: "work comment", ops: &["add_work_item_comment", "upload_attachment"] }, | |
| 265 | + | Mapping { command: "work new", ops: &["open_work_item", "update_work_item", "upload_attachment", "upload_work_item_attachment"] }, | |
| 266 | + | Mapping { command: "work comment", ops: &["add_work_item_comment", "upload_work_item_attachment"] }, | |
| 267 | 267 | Mapping { command: "work edit", ops: &["update_work_item", "get_work_item"] }, | |
| 268 | 268 | Mapping { command: "work close", ops: &["close_work_item", "add_work_item_comment"] }, | |
| 269 | 269 | Mapping { command: "work reopen", ops: &["reopen_work_item", "add_work_item_comment"] }, | |
| ⋯ | |||
| 278 | 278 | Mapping { command: "work events", ops: &["list_events"] }, | |
| 279 | 279 | Mapping { command: "work dispatch", ops: &["start_agent_session"] }, | |
| 280 | 280 | Mapping { command: "why", ops: &["why"] }, | |
| 281 | − | Mapping { command: "attach", ops: &["upload_attachment"] }, | |
| 281 | + | Mapping { command: "attach", ops: &["upload_attachment", "upload_work_item_attachment"] }, | |
| 282 | 282 | // Every operation, by name: the table generated from the OpenAPI document. | |
| 283 | 283 | Mapping { | |
| 284 | 284 | command: "api", | |
| 7315 | 7315 | ], | |
| 7316 | 7316 | }, | |
| 7317 | 7317 | Operation { | |
| 7318 | + | id: "upload_work_item_attachment", | |
| 7319 | + | method: "POST", | |
| 7320 | + | path: "/workspaces/{workspace}/work/{number}/attachments", | |
| 7321 | + | tag: "Work items", | |
| 7322 | + | summary: "Upload an attachment to a work item", | |
| 7323 | + | scope: Some("work:write"), | |
| 7324 | + | params: &[ | |
| 7325 | + | Param { name: "workspace", location: In::Path, kind: "string", required: true, values: &[] }, | |
| 7326 | + | Param { name: "number", location: In::Path, kind: "integer", required: true, values: &[] }, | |
| 7327 | + | ], | |
| 7328 | + | body: &[ | |
| 7329 | + | Field { name: "content_type", kind: "string", required: false, values: &["image/png", "image/jpeg", "image/gif", "image/webp"] }, | |
| 7330 | + | Field { name: "data", kind: "string", required: true, values: &[] }, | |
| 7331 | + | Field { name: "name", kind: "string", required: true, values: &[] }, | |
| 7332 | + | Field { name: "repo", kind: "string", required: false, values: &[] }, | |
| 7333 | + | ], | |
| 7334 | + | }, | |
| 7335 | + | Operation { | |
| 7318 | 7336 | id: "create_workspace", | |
| 7319 | 7337 | method: "POST", | |
| 7320 | 7338 | path: "/workspaces", |
| 505 | 505 | ["delete_work_item_comment", "work:write"], | |
| 506 | 506 | ["react_to_work_item", "work:write"], | |
| 507 | 507 | ["upload_attachment", "work:write"], | |
| 508 | + | ["upload_work_item_attachment", "work:write"], | |
| 508 | 509 | ["create_commit_comment", "work:write"], | |
| 509 | 510 | ["update_commit_comment", "work:write"], | |
| 510 | 511 | ["delete_commit_comment", "work:write"], |
| 3 | 3 | version = "0.1.0" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | license.workspace = true | |
| 6 | − | description = "Issues, pull requests, reviews, checks and sessions." | |
| 6 | + | description = "Work items, pull requests, reviews, checks and sessions." | |
| 7 | 7 | ||
| 8 | 8 | [lib] | |
| 9 | 9 | crate-type = ["cdylib"] |
| 1 | + | -- Attachments on work items (attachments.rs): a work item filed without a | |
| 2 | + | -- repository takes files too, kept by the work item (`item_id`, with its | |
| 3 | + | -- `workspace`, so a deleted workspace's go with it) instead of a | |
| 4 | + | -- repository. Each row names one or the other. | |
| 5 | + | CREATE TABLE attachments_new ( | |
| 6 | + | id TEXT PRIMARY KEY, | |
| 7 | + | repo_id TEXT, | |
| 8 | + | item_id TEXT, | |
| 9 | + | workspace TEXT, | |
| 10 | + | user_id TEXT NOT NULL, | |
| 11 | + | name TEXT NOT NULL, | |
| 12 | + | content_type TEXT NOT NULL, | |
| 13 | + | size INTEGER NOT NULL, | |
| 14 | + | created_at TEXT NOT NULL, | |
| 15 | + | CHECK (repo_id IS NOT NULL OR item_id IS NOT NULL) | |
| 16 | + | ); | |
| 17 | + | ||
| 18 | + | INSERT INTO attachments_new (id, repo_id, user_id, name, content_type, size, created_at) | |
| 19 | + | SELECT id, repo_id, user_id, name, content_type, size, created_at FROM attachments; | |
| 20 | + | ||
| 21 | + | DROP TABLE attachments; | |
| 22 | + | ALTER TABLE attachments_new RENAME TO attachments; | |
| 23 | + | ||
| 24 | + | CREATE INDEX attachments_by_repo ON attachments (repo_id); | |
| 25 | + | CREATE INDEX attachments_by_workspace ON attachments (workspace) WHERE workspace IS NOT NULL; |
Comments
Sign in to comment.