@@ -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
40254185impl 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