Skip to content

Commit 40ae242

Browse files
Add GitHub-anchored attachment variants to Attachment enum (#1823)
* Add GitHub-anchored attachment variants to Attachment enum Add the nine GitHub-anchored attachment variants (github_commit, github_release, github_actions_job, github_repository, github_file_diff, github_tree_comparison, github_url, github_file, github_snippet) to the hand-written `Attachment` enum so copilotd no longer drops them when (de)serializing at the SDK boundary. The fields are inlined directly into each variant, mirroring the existing github_reference variant, rather than wrapping the generated `AttachmentGitHub*` structs. Wrapping would emit a duplicate `type` JSON key (the generated structs embed their own discriminator) and break the enum's `Eq` derive (the generated structs do not derive Eq). Nested object fields use local sub-structs that derive Eq. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make attachment match arms explicit and harden duplicate-type test List github_reference + the 9 GitHub-anchored variants explicitly in the display-name match arms instead of a wildcard, so future Attachment variants trigger a compile error and force an explicit display-behavior decision. Count raw "type": occurrences in the serialized string rather than keys on a parsed Value (which silently dedupes), so the test can actually catch a duplicate-type regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8c3ecbd commit 40ae242

1 file changed

Lines changed: 337 additions & 3 deletions

File tree

rust/src/types.rs

Lines changed: 337 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3956,6 +3956,55 @@ pub enum GitHubReferenceType {
39563956
Discussion,
39573957
}
39583958

3959+
/// Pointer to a GitHub repository (owner/name plus optional numeric id).
3960+
///
3961+
/// Used by the GitHub-anchored [`Attachment`] variants. Mirrors the field
3962+
/// shape of the generated `GitHubRepoRef`, but defined locally so it can
3963+
/// derive `Eq` for use inside the `Attachment` enum.
3964+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3965+
#[serde(rename_all = "camelCase")]
3966+
pub struct GitHubRepoPointer {
3967+
/// Numeric GitHub repository id.
3968+
#[serde(skip_serializing_if = "Option::is_none")]
3969+
pub id: Option<i64>,
3970+
/// Repository name (without owner).
3971+
pub name: String,
3972+
/// Repository owner login (user or organization).
3973+
pub owner: String,
3974+
}
3975+
3976+
/// One side (head or base) of a GitHub single-file diff.
3977+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3978+
#[serde(rename_all = "camelCase")]
3979+
pub struct GitHubFileDiffSide {
3980+
/// Repository-relative path to the file.
3981+
pub path: String,
3982+
/// Git ref (branch, tag, or commit SHA) the file is read at.
3983+
pub r#ref: String,
3984+
/// Repository the file lives in.
3985+
pub repo: GitHubRepoPointer,
3986+
}
3987+
3988+
/// One side (head or base) of a GitHub tree comparison.
3989+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3990+
#[serde(rename_all = "camelCase")]
3991+
pub struct GitHubTreeComparisonSide {
3992+
/// Repository the revision belongs to.
3993+
pub repo: GitHubRepoPointer,
3994+
/// Git revision (branch, tag, or commit SHA).
3995+
pub revision: String,
3996+
}
3997+
3998+
/// Line range covered by a GitHub snippet attachment (1-based, inclusive end).
3999+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4000+
#[serde(rename_all = "camelCase")]
4001+
pub struct GitHubSnippetLineRange {
4002+
/// Start line number (1-based).
4003+
pub start: i64,
4004+
/// End line number (1-based, inclusive).
4005+
pub end: i64,
4006+
}
4007+
39594008
/// An attachment included with a user message.
39604009
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39614010
#[serde(
@@ -4020,6 +4069,117 @@ pub enum Attachment {
40204069
/// URL to the referenced item.
40214070
url: String,
40224071
},
4072+
/// A pointer to a GitHub commit.
4073+
#[serde(rename = "github_commit")]
4074+
GitHubCommit {
4075+
/// First line of the commit message.
4076+
message: String,
4077+
/// Full commit SHA.
4078+
oid: String,
4079+
/// Repository the commit belongs to.
4080+
repo: GitHubRepoPointer,
4081+
/// URL to the commit on GitHub.
4082+
url: String,
4083+
},
4084+
/// A pointer to a GitHub release.
4085+
#[serde(rename = "github_release")]
4086+
GitHubRelease {
4087+
/// Human-readable release name.
4088+
name: String,
4089+
/// Repository the release belongs to.
4090+
repo: GitHubRepoPointer,
4091+
/// Git tag the release is anchored to.
4092+
tag_name: String,
4093+
/// URL to the release on GitHub.
4094+
url: String,
4095+
},
4096+
/// A pointer to a GitHub Actions job.
4097+
#[serde(rename = "github_actions_job")]
4098+
GitHubActionsJob {
4099+
/// Terminal conclusion of the job when finished (e.g. "success",
4100+
/// "failure", "cancelled"). Absent for in-progress jobs.
4101+
#[serde(skip_serializing_if = "Option::is_none")]
4102+
conclusion: Option<String>,
4103+
/// Job id within the workflow run.
4104+
job_id: i64,
4105+
/// Display name of the job.
4106+
job_name: String,
4107+
/// Repository the workflow run belongs to.
4108+
repo: GitHubRepoPointer,
4109+
/// URL to the job on GitHub.
4110+
url: String,
4111+
/// Display name of the workflow the job ran in.
4112+
workflow_name: String,
4113+
},
4114+
/// A pointer to a GitHub repository.
4115+
#[serde(rename = "github_repository")]
4116+
GitHubRepository {
4117+
/// Short description of the repository.
4118+
#[serde(skip_serializing_if = "Option::is_none")]
4119+
description: Option<String>,
4120+
/// Git ref this attachment is anchored at (branch, tag, or commit).
4121+
/// When absent the default branch is implied.
4122+
#[serde(skip_serializing_if = "Option::is_none")]
4123+
r#ref: Option<String>,
4124+
/// Repository pointer.
4125+
repo: GitHubRepoPointer,
4126+
/// URL to the repository on GitHub.
4127+
url: String,
4128+
},
4129+
/// A pointer to a single-file diff. At least one of `head` and `base` is present.
4130+
#[serde(rename = "github_file_diff")]
4131+
GitHubFileDiff {
4132+
/// File location on the base side of the diff. Absent for additions.
4133+
#[serde(skip_serializing_if = "Option::is_none")]
4134+
base: Option<GitHubFileDiffSide>,
4135+
/// File location on the head side of the diff. Absent for deletions.
4136+
#[serde(skip_serializing_if = "Option::is_none")]
4137+
head: Option<GitHubFileDiffSide>,
4138+
/// URL to the diff on GitHub (e.g. a commit, compare, or PR-file URL).
4139+
url: String,
4140+
},
4141+
/// A pointer to a comparison between two git revisions.
4142+
#[serde(rename = "github_tree_comparison")]
4143+
GitHubTreeComparison {
4144+
/// Base side of the comparison.
4145+
base: GitHubTreeComparisonSide,
4146+
/// Head side of the comparison.
4147+
head: GitHubTreeComparisonSide,
4148+
/// URL to the comparison on GitHub.
4149+
url: String,
4150+
},
4151+
/// A generic GitHub URL reference.
4152+
#[serde(rename = "github_url")]
4153+
GitHubUrl {
4154+
/// URL to the GitHub resource.
4155+
url: String,
4156+
},
4157+
/// A pointer to a file in a GitHub repository at a specific ref.
4158+
#[serde(rename = "github_file")]
4159+
GitHubFile {
4160+
/// Repository-relative path to the file.
4161+
path: String,
4162+
/// Git ref the file is read at (branch, tag, or commit SHA).
4163+
r#ref: String,
4164+
/// Repository the file lives in.
4165+
repo: GitHubRepoPointer,
4166+
/// URL to the file on GitHub.
4167+
url: String,
4168+
},
4169+
/// A pointer to a line range inside a file in a GitHub repository.
4170+
#[serde(rename = "github_snippet")]
4171+
GitHubSnippet {
4172+
/// Line range the snippet covers.
4173+
line_range: GitHubSnippetLineRange,
4174+
/// Repository-relative path to the file.
4175+
path: String,
4176+
/// Git ref the file is read at (branch, tag, or commit SHA).
4177+
r#ref: String,
4178+
/// Repository the file lives in.
4179+
repo: GitHubRepoPointer,
4180+
/// URL to the snippet on GitHub (with line anchor).
4181+
url: String,
4182+
},
40234183
}
40244184

40254185
impl Attachment {
@@ -4030,7 +4190,16 @@ impl Attachment {
40304190
| Self::Directory { display_name, .. }
40314191
| Self::Selection { display_name, .. }
40324192
| Self::Blob { display_name, .. } => display_name.as_deref(),
4033-
Self::GitHubReference { .. } => None,
4193+
Self::GitHubReference { .. }
4194+
| Self::GitHubCommit { .. }
4195+
| Self::GitHubRelease { .. }
4196+
| Self::GitHubActionsJob { .. }
4197+
| Self::GitHubRepository { .. }
4198+
| Self::GitHubFileDiff { .. }
4199+
| Self::GitHubTreeComparison { .. }
4200+
| Self::GitHubUrl { .. }
4201+
| Self::GitHubFile { .. }
4202+
| Self::GitHubSnippet { .. } => None,
40344203
}
40354204
}
40364205

@@ -4073,7 +4242,16 @@ impl Attachment {
40734242
| Self::Directory { display_name, .. }
40744243
| Self::Selection { display_name, .. }
40754244
| Self::Blob { display_name, .. } => *display_name = Some(derived_display_name),
4076-
Self::GitHubReference { .. } => {}
4245+
Self::GitHubReference { .. }
4246+
| Self::GitHubCommit { .. }
4247+
| Self::GitHubRelease { .. }
4248+
| Self::GitHubActionsJob { .. }
4249+
| Self::GitHubRepository { .. }
4250+
| Self::GitHubFileDiff { .. }
4251+
| Self::GitHubTreeComparison { .. }
4252+
| Self::GitHubUrl { .. }
4253+
| Self::GitHubFile { .. }
4254+
| Self::GitHubSnippet { .. } => {}
40774255
}
40784256
}
40794257

@@ -4084,7 +4262,16 @@ impl Attachment {
40844262
}
40854263
Self::Selection { file_path, .. } => Some(attachment_name_from_path(file_path)),
40864264
Self::Blob { .. } => Some("attachment".to_string()),
4087-
Self::GitHubReference { .. } => None,
4265+
Self::GitHubReference { .. }
4266+
| Self::GitHubCommit { .. }
4267+
| Self::GitHubRelease { .. }
4268+
| Self::GitHubActionsJob { .. }
4269+
| Self::GitHubRepository { .. }
4270+
| Self::GitHubFileDiff { .. }
4271+
| Self::GitHubTreeComparison { .. }
4272+
| Self::GitHubUrl { .. }
4273+
| Self::GitHubFile { .. }
4274+
| Self::GitHubSnippet { .. } => None,
40884275
}
40894276
}
40904277
}
@@ -6040,6 +6227,153 @@ mod tests {
60406227
Some("Track regressions".to_string())
60416228
);
60426229
}
6230+
6231+
#[test]
6232+
fn github_anchored_attachment_variants_round_trip() {
6233+
let cases = vec![
6234+
(
6235+
"github_commit",
6236+
json!({
6237+
"type": "github_commit",
6238+
"message": "Fix the thing",
6239+
"oid": "abc123",
6240+
"repo": { "id": 1, "name": "repo", "owner": "octocat" },
6241+
"url": "https://github.com/octocat/repo/commit/abc123"
6242+
}),
6243+
),
6244+
(
6245+
"github_release",
6246+
json!({
6247+
"type": "github_release",
6248+
"name": "v1.2.3",
6249+
"repo": { "name": "repo", "owner": "octocat" },
6250+
"tagName": "v1.2.3",
6251+
"url": "https://github.com/octocat/repo/releases/tag/v1.2.3"
6252+
}),
6253+
),
6254+
(
6255+
"github_actions_job",
6256+
json!({
6257+
"type": "github_actions_job",
6258+
"conclusion": "failure",
6259+
"jobId": 99,
6260+
"jobName": "build",
6261+
"repo": { "name": "repo", "owner": "octocat" },
6262+
"url": "https://github.com/octocat/repo/actions/runs/1/job/99",
6263+
"workflowName": "CI"
6264+
}),
6265+
),
6266+
(
6267+
"github_repository",
6268+
json!({
6269+
"type": "github_repository",
6270+
"description": "An example repository",
6271+
"ref": "main",
6272+
"repo": { "name": "repo", "owner": "octocat" },
6273+
"url": "https://github.com/octocat/repo"
6274+
}),
6275+
),
6276+
(
6277+
"github_file_diff",
6278+
json!({
6279+
"type": "github_file_diff",
6280+
"base": {
6281+
"path": "src/lib.rs",
6282+
"ref": "main",
6283+
"repo": { "name": "repo", "owner": "octocat" }
6284+
},
6285+
"head": {
6286+
"path": "src/lib.rs",
6287+
"ref": "feature",
6288+
"repo": { "name": "repo", "owner": "octocat" }
6289+
},
6290+
"url": "https://github.com/octocat/repo/compare/main...feature"
6291+
}),
6292+
),
6293+
(
6294+
"github_tree_comparison",
6295+
json!({
6296+
"type": "github_tree_comparison",
6297+
"base": {
6298+
"repo": { "name": "repo", "owner": "octocat" },
6299+
"revision": "main"
6300+
},
6301+
"head": {
6302+
"repo": { "name": "repo", "owner": "octocat" },
6303+
"revision": "feature"
6304+
},
6305+
"url": "https://github.com/octocat/repo/compare/main...feature"
6306+
}),
6307+
),
6308+
(
6309+
"github_url",
6310+
json!({
6311+
"type": "github_url",
6312+
"url": "https://github.com/octocat/repo/wiki"
6313+
}),
6314+
),
6315+
(
6316+
"github_file",
6317+
json!({
6318+
"type": "github_file",
6319+
"path": "src/main.rs",
6320+
"ref": "main",
6321+
"repo": { "name": "repo", "owner": "octocat" },
6322+
"url": "https://github.com/octocat/repo/blob/main/src/main.rs"
6323+
}),
6324+
),
6325+
(
6326+
"github_snippet",
6327+
json!({
6328+
"type": "github_snippet",
6329+
"lineRange": { "start": 10, "end": 20 },
6330+
"path": "src/main.rs",
6331+
"ref": "main",
6332+
"repo": { "name": "repo", "owner": "octocat" },
6333+
"url": "https://github.com/octocat/repo/blob/main/src/main.rs#L10-L20"
6334+
}),
6335+
),
6336+
];
6337+
6338+
for (expected_type, input) in cases {
6339+
let attachment: Attachment = serde_json::from_value(input.clone())
6340+
.unwrap_or_else(|err| panic!("{expected_type} should deserialize: {err}"));
6341+
6342+
// Serialize to a string first: parsing into `serde_json::Value` would
6343+
// silently dedupe a duplicate `type` key, hiding the exact regression
6344+
// this test guards against (e.g. a wrapped generated struct emitting its
6345+
// own `type` alongside the enum tag).
6346+
let serialized_string = serde_json::to_string(&attachment)
6347+
.unwrap_or_else(|err| panic!("{expected_type} should serialize: {err}"));
6348+
6349+
// Exactly one `type` key, carrying the expected discriminator.
6350+
assert_eq!(
6351+
serialized_string.matches("\"type\":").count(),
6352+
1,
6353+
"{expected_type} must serialize a single `type` key"
6354+
);
6355+
6356+
let serialized: serde_json::Value = serde_json::from_str(&serialized_string)
6357+
.unwrap_or_else(|err| panic!("{expected_type} should reparse: {err}"));
6358+
assert_eq!(
6359+
serialized.get("type").and_then(|value| value.as_str()),
6360+
Some(expected_type),
6361+
"{expected_type} must serialize the correct discriminator"
6362+
);
6363+
6364+
// Round-trips without dropping fields.
6365+
assert_eq!(
6366+
serialized, input,
6367+
"{expected_type} should round-trip without data loss"
6368+
);
6369+
let reparsed: Attachment = serde_json::from_value(serialized)
6370+
.unwrap_or_else(|err| panic!("{expected_type} should re-deserialize: {err}"));
6371+
assert_eq!(
6372+
reparsed, attachment,
6373+
"{expected_type} should re-deserialize to the same value"
6374+
);
6375+
}
6376+
}
60436377
}
60446378

60456379
#[cfg(test)]

0 commit comments

Comments
 (0)