Repository: reviewdog/reviewdog
Pull request #1770 (2024-06-09 10:17:39)

Add related location support
- [x] Updated Unreleased section in [CHANGELOG](https://github.com/reviewdog/reviewdog/blob/master/CHANGELOG.md) or it's not notable changes.

This PR adds related_locations support for github-pr-review reporter.

![image](https://github.com/reviewdog/reviewdog/assets/3797062/6b6a4e53-33dd-4006-8446-af3dbb6becaa)


The similar property is available in SARIF too. I found statickcheck outputs related location like this. We can support this kind of use cases. 

Example staticcheck output

```json
{
  "code": "SA4031",
  "severity": "error",
  "location": {
    "file": "/home/haya14busa/src/github.com/reviewdog/action-staticcheck/testdata/testdata.go",
    "line": 24,
    "column": 5
  },
  "end": {
    "file": "/home/haya14busa/src/github.com/reviewdog/action-staticcheck/testdata/testdata.go",
    "line": 24,
    "column": 13
  },
  "message": "this nil check is always true",
  "related": [
    {
      "location": {
        "file": "/home/haya14busa/src/github.com/reviewdog/action-staticcheck/testdata/testdata.go",
        "line": 23,
        "column": 7
      },
      "end": {
        "file": "/home/haya14busa/src/github.com/reviewdog/action-staticcheck/testdata/testdata.go",
        "line": 23,
        "column": 17
      },
      "message": "this is the value of s"
    }
  ]
}
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36560e06..9589cc4a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### :sparkles: Release Note <!-- optional -->
 
+This release supports related locations for`github-pr-review` reporter.
+You can pass `related_locations` with `rdjson` / `rdjsonl` format.
+
+![related locations support](https://github.com/reviewdog/reviewdog/assets/3797062/6b6a4e53-33dd-4006-8446-af3dbb6becaa)
+
 ### :rocket: Enhancements
+- [#1770](https://github.com/reviewdog/reviewdog/pull/1770) Add related locations support for `github-pr-review` reporter
 - ...
 
 ### :bug: Fixes
diff --git a/proto/rdf/jsonschema/Diagnostic.json b/proto/rdf/jsonschema/Diagnostic.json
index 256160dc..b14b010d 100644
--- a/proto/rdf/jsonschema/Diagnostic.json
+++ b/proto/rdf/jsonschema/Diagnostic.json
@@ -54,6 +54,13 @@
                 "original_output": {
                     "type": "string",
                     "description": "Experimental: If this diagnostic is converted from other formats, original_output represents the original output which corresponds to this diagnostic. Optional."
+                },
+                "related_locations": {
+                    "items": {
+                        "$ref": "#/definitions/reviewdog.rdf.RelatedLocation"
+                    },
+                    "type": "array",
+                    "description": "Related locations for this diagnostic. Optional."
                 }
             },
             "additionalProperties": true,
@@ -125,6 +132,22 @@
             "title": "The end position is *exclusive*. It might be a bit unnatural for you or for\n some diagnostic tools to use exclusive range, but it's necessary to represent\n zero-width range especially when using it in Suggestion context to support\n code insertion.\n Example: \"14\" in \"haya14busa\"\n   start: { line: 1, column: 5 }\n   end:   { line: 1, column: 7 } # \u003c= Exclusive\n\n |h|a|y|a|1|4|b|u|s|a|\n 1 2 3 4 5 6 7 8 9 0 1\n         ^---^\n haya14busa\n     ^^\n\n If you want to specify a range that\n contains a line including the line ending character(s), then use an end\n position denoting the start of the next line.\n Example:\n   start: { line: 5, column: 23 }\n   end:   { line: 6, column: 1 }\n\n If both start and end position omit column value, it's\n handled as linewise and the range includes end position (line) as well.\n Example:\n   start: { line: 5 }\n   end:   { line: 6 }\n The above example represents range start from line 5 to the end of line 6\n including EOL.\n\n Examples for line range:\n  Text example. \u003cline\u003e|\u003cline content\u003e(line breaking)\n  1|abc\\r\\n\n  2|def\\r\\n\n  3|ghi\\r\\n\n\n start: { line: 2 }\n   =\u003e \"abc\"\n\n start: { line: 2 }\n end:   { line: 2 }\n   =\u003e \"abc\"\n\n start: { line: 2 }\n end:   { line: 3 }\n   =\u003e \"abc\\r\\ndef\"\n\n start: { line: 2 }\n end:   { line: 3, column: 1 }\n   =\u003e \"abc\\r\\n\"",
             "description": "A range in a text document expressed as start and end positions.  The end position is *exclusive*. It might be a bit unnatural for you or for some diagnostic tools to use exclusive range, but it's necessary to represent zero-width range especially when using it in Suggestion context to support code insertion. Example: \"14\" in \"haya14busa\"   start: { line: 1, column: 5 }   end:   { line: 1, column: 7 } # \u003c= Exclusive |h|a|y|a|1|4|b|u|s|a| 1 2 3 4 5 6 7 8 9 0 1         ^---^ haya14busa     ^^ If you want to specify a range that contains a line including the line ending character(s), then use an end position denoting the start of the next line. Example:   start: { line: 5, column: 23 }   end:   { line: 6, column: 1 } If both start and end position omit column value, it's handled as linewise and the range includes end position (line) as well. Example:   start: { line: 5 }   end:   { line: 6 } The above example represents range start from line 5 to the end of line 6 including EOL. Examples for line range:  Text example. \u003cline\u003e|\u003cline content\u003e(line breaking)  1|abc\\r\\n  2|def\\r\\n  3|ghi\\r\\n start: { line: 2 }   =\u003e \"abc\" start: { line: 2 } end:   { line: 2 }   =\u003e \"abc\" start: { line: 2 } end:   { line: 3 }   =\u003e \"abc\\r\\ndef\" start: { line: 2 } end:   { line: 3, column: 1 }   =\u003e \"abc\\r\\n\"  start: { line: 2, column: 1 } end:   { line: 2, column: 4 }   =\u003e \"abc\" (without line-break)"
         },
+        "reviewdog.rdf.RelatedLocation": {
+            "properties": {
+                "message": {
+                    "type": "string",
+                    "description": "Explanation of this related location. Optional."
+                },
+                "location": {
+                    "$ref": "#/definitions/reviewdog.rdf.Location",
+                    "additionalProperties": true,
+                    "description": "Required."
+                }
+            },
+            "additionalProperties": true,
+            "type": "object",
+            "title": "Related Location"
+        },
         "reviewdog.rdf.Source": {
             "properties": {
                 "name": {
diff --git a/proto/rdf/jsonschema/DiagnosticResult.json b/proto/rdf/jsonschema/DiagnosticResult.json
index eae3aeeb..8cb205b6 100644
--- a/proto/rdf/jsonschema/DiagnosticResult.json
+++ b/proto/rdf/jsonschema/DiagnosticResult.json
@@ -109,6 +109,13 @@
                 "original_output": {
                     "type": "string",
                     "description": "Experimental: If this diagnostic is converted from other formats, original_output represents the original output which corresponds to this diagnostic. Optional."
+                },
+                "related_locations": {
+                    "items": {
+                        "$ref": "#/definitions/reviewdog.rdf.RelatedLocation"
+                    },
+                    "type": "array",
+                    "description": "Related locations for this diagnostic. Optional."
                 }
             },
             "additionalProperties": true,
@@ -165,6 +172,22 @@
             "title": "The end position is *exclusive*. It might be a bit unnatural for you or for\n some diagnostic tools to use exclusive range, but it's necessary to represent\n zero-width range especially when using it in Suggestion context to support\n code insertion.\n Example: \"14\" in \"haya14busa\"\n   start: { line: 1, column: 5 }\n   end:   { line: 1, column: 7 } # \u003c= Exclusive\n\n |h|a|y|a|1|4|b|u|s|a|\n 1 2 3 4 5 6 7 8 9 0 1\n         ^---^\n haya14busa\n     ^^\n\n If you want to specify a range that\n contains a line including the line ending character(s), then use an end\n position denoting the start of the next line.\n Example:\n   start: { line: 5, column: 23 }\n   end:   { line: 6, column: 1 }\n\n If both start and end position omit column value, it's\n handled as linewise and the range includes end position (line) as well.\n Example:\n   start: { line: 5 }\n   end:   { line: 6 }\n The above example represents range start from line 5 to the end of line 6\n including EOL.\n\n Examples for line range:\n  Text example. \u003cline\u003e|\u003cline content\u003e(line breaking)\n  1|abc\\r\\n\n  2|def\\r\\n\n  3|ghi\\r\\n\n\n start: { line: 2 }\n   =\u003e \"abc\"\n\n start: { line: 2 }\n end:   { line: 2 }\n   =\u003e \"abc\"\n\n start: { line: 2 }\n end:   { line: 3 }\n   =\u003e \"abc\\r\\ndef\"\n\n start: { line: 2 }\n end:   { line: 3, column: 1 }\n   =\u003e \"abc\\r\\n\"",
             "description": "A range in a text document expressed as start and end positions.  The end position is *exclusive*. It might be a bit unnatural for you or for some diagnostic tools to use exclusive range, but it's necessary to represent zero-width range especially when using it in Suggestion context to support code insertion. Example: \"14\" in \"haya14busa\"   start: { line: 1, column: 5 }   end:   { line: 1, column: 7 } # \u003c= Exclusive |h|a|y|a|1|4|b|u|s|a| 1 2 3 4 5 6 7 8 9 0 1         ^---^ haya14busa     ^^ If you want to specify a range that contains a line including the line ending character(s), then use an end position denoting the start of the next line. Example:   start: { line: 5, column: 23 }   end:   { line: 6, column: 1 } If both start and end position omit column value, it's handled as linewise and the range includes end position (line) as well. Example:   start: { line: 5 }   end:   { line: 6 } The above example represents range start from line 5 to the end of line 6 including EOL. Examples for line range:  Text example. \u003cline\u003e|\u003cline content\u003e(line breaking)  1|abc\\r\\n  2|def\\r\\n  3|ghi\\r\\n start: { line: 2 }   =\u003e \"abc\" start: { line: 2 } end:   { line: 2 }   =\u003e \"abc\" start: { line: 2 } end:   { line: 3 }   =\u003e \"abc\\r\\ndef\" start: { line: 2 } end:   { line: 3, column: 1 }   =\u003e \"abc\\r\\n\"  start: { line: 2, column: 1 } end:   { line: 2, column: 4 }   =\u003e \"abc\" (without line-break)"
         },
+        "reviewdog.rdf.RelatedLocation": {
+            "properties": {
+                "message": {
+                    "type": "string",
+                    "description": "Explanation of this related location. Optional."
+                },
+                "location": {
+                    "$ref": "#/definitions/reviewdog.rdf.Location",
+                    "additionalProperties": true,
+                    "description": "Required."
+                }
+            },
+            "additionalProperties": true,
+            "type": "object",
+            "title": "Related Location"
+        },
         "reviewdog.rdf.Source": {
             "properties": {
                 "name": {
diff --git a/proto/rdf/jsonschema/RelatedLocation.json b/proto/rdf/jsonschema/RelatedLocation.json
new file mode 100644
index 00000000..22eafbcc
--- /dev/null
+++ b/proto/rdf/jsonschema/RelatedLocation.json
@@ -0,0 +1,71 @@
+{
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "$ref": "#/definitions/RelatedLocation",
+    "definitions": {
+        "RelatedLocation": {
+            "properties": {
+                "message": {
+                    "type": "string",
+                    "description": "Explanation of this related location. Optional."
+                },
+                "location": {
+                    "$ref": "#/definitions/reviewdog.rdf.Location",
+                    "additionalProperties": true,
+                    "description": "Required."
+                }
+            },
+            "additionalProperties": true,
+            "type": "object",
+            "title": "Related Location"
+        },
+        "reviewdog.rdf.Location": {
+            "properties": {
+                "path": {
+                    "type": "string",
+                    "description": "File path. It could be either absolute path or relative path."
+                },
+                "range": {
+                    "$ref": "#/definitions/reviewdog.rdf.Range",
+                    "additionalProperties": true,
+                    "description": "Range in the file path. Optional."
+                }
+            },
+            "additionalProperties": true,
+            "type": "object",
+            "title": "Location"
+        },
+        "reviewdog.rdf.Position": {
+            "properties": {
+                "line": {
+                    "type": "integer",
+                    "description": "Line number, starting at 1. Optional."
+                },
+                "column": {
+                    "type": "integer",
+                    "description": "Column number, starting at 1 (byte count in UTF-8). Example: 'a𐐀b'  The column of a: 1  The column of 𐐀: 2  The column of b: 6 since 𐐀 is represented with 4 bytes in UTF-8. Optional."
+                }
+            },
+            "additionalProperties": true,
+            "type": "object",
+            "title": "Position"
+        },
+        "reviewdog.rdf.Range": {
+            "properties": {
+                "start": {
+                    "$ref": "#/definitions/reviewdog.rdf.Position",
+                    "additionalProperties": true,
+                    "description": "Required."
+                },
+                "end": {
+                    "$ref": "#/definitions/reviewdog.rdf.Position",
+                    "additionalProperties": true,
+                    "description": "end can be omitted. Then the range is handled as zero-length (start == end). Optional."
+                }
+            },
+            "additionalProperties": true,
+            "type": "object",
+            "title": "The end position is *exclusive*. It might be a bit unnatural for you or for\n some diagnostic tools to use exclusive range, but it's necessary to represent\n zero-width range especially when using it in Suggestion context to support\n code insertion.\n Example: \"14\" in \"haya14busa\"\n   start: { line: 1, column: 5 }\n   end:   { line: 1, column: 7 } # \u003c= Exclusive\n\n |h|a|y|a|1|4|b|u|s|a|\n 1 2 3 4 5 6 7 8 9 0 1\n         ^---^\n haya14busa\n     ^^\n\n If you want to specify a range that\n contains a line including the line ending character(s), then use an end\n position denoting the start of the next line.\n Example:\n   start: { line: 5, column: 23 }\n   end:   { line: 6, column: 1 }\n\n If both start and end position omit column value, it's\n handled as linewise and the range includes end position (line) as well.\n Example:\n   start: { line: 5 }\n   end:   { line: 6 }\n The above example represents range start from line 5 to the end of line 6\n including EOL.\n\n Examples for line range:\n  Text example. \u003cline\u003e|\u003cline content\u003e(line breaking)\n  1|abc\\r\\n\n  2|def\\r\\n\n  3|ghi\\r\\n\n\n start: { line: 2 }\n   =\u003e \"abc\"\n\n start: { line: 2 }\n end:   { line: 2 }\n   =\u003e \"abc\"\n\n start: { line: 2 }\n end:   { line: 3 }\n   =\u003e \"abc\\r\\ndef\"\n\n start: { line: 2 }\n end:   { line: 3, column: 1 }\n   =\u003e \"abc\\r\\n\"",
+            "description": "A range in a text document expressed as start and end positions.  The end position is *exclusive*. It might be a bit unnatural for you or for some diagnostic tools to use exclusive range, but it's necessary to represent zero-width range especially when using it in Suggestion context to support code insertion. Example: \"14\" in \"haya14busa\"   start: { line: 1, column: 5 }   end:   { line: 1, column: 7 } # \u003c= Exclusive |h|a|y|a|1|4|b|u|s|a| 1 2 3 4 5 6 7 8 9 0 1         ^---^ haya14busa     ^^ If you want to specify a range that contains a line including the line ending character(s), then use an end position denoting the start of the next line. Example:   start: { line: 5, column: 23 }   end:   { line: 6, column: 1 } If both start and end position omit column value, it's handled as linewise and the range includes end position (line) as well. Example:   start: { line: 5 }   end:   { line: 6 } The above example represents range start from line 5 to the end of line 6 including EOL. Examples for line range:  Text example. \u003cline\u003e|\u003cline content\u003e(line breaking)  1|abc\\r\\n  2|def\\r\\n  3|ghi\\r\\n start: { line: 2 }   =\u003e \"abc\" start: { line: 2 } end:   { line: 2 }   =\u003e \"abc\" start: { line: 2 } end:   { line: 3 }   =\u003e \"abc\\r\\ndef\" start: { line: 2 } end:   { line: 3, column: 1 }   =\u003e \"abc\\r\\n\"  start: { line: 2, column: 1 } end:   { line: 2, column: 4 }   =\u003e \"abc\" (without line-break)"
+        }
+    }
+}
\ No newline at end of file
diff --git a/proto/rdf/reviewdog.pb.go b/proto/rdf/reviewdog.pb.go
index 4707046f..5573d4fa 100644
--- a/proto/rdf/reviewdog.pb.go
+++ b/proto/rdf/reviewdog.pb.go
@@ -186,6 +186,9 @@ type Diagnostic struct {
 	// diagnostic.
 	// Optional.
 	OriginalOutput string `protobuf:"bytes,7,opt,name=original_output,json=originalOutput,proto3" json:"original_output,omitempty"`
+	// Related locations for this diagnostic.
+	// Optional.
+	RelatedLocations []*RelatedLocation `protobuf:"bytes,8,rep,name=related_locations,json=relatedLocations,proto3" json:"related_locations,omitempty"`
 }
 
 func (x *Diagnostic) Reset() {
@@ -269,6 +272,13 @@ func (x *Diagnostic) GetOriginalOutput() string {
 	return ""
 }
 
+func (x *Diagnostic) GetRelatedLocations() []*RelatedLocation {
+	if x != nil {
+		return x.RelatedLocations
+	}
+	return nil
+}
+
 type Location struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -327,6 +337,64 @@ func (x *Location) GetRange() *Range {
 	return nil
 }
 
+type RelatedLocation struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Explanation of this related location.
+	// Optional.
+	Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
+	// Required.
+	Location *Location `protobuf:"bytes,2,opt,name=location,proto3" json:"location,omitempty"`
+}
+
+func (x *RelatedLocation) Reset() {
+	*x = RelatedLocation{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_reviewdog_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *RelatedLocation) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RelatedLocation) ProtoMessage() {}
+
+func (x *RelatedLocation) ProtoReflect() protoreflect.Message {
+	mi := &file_reviewdog_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use RelatedLocation.ProtoReflect.Descriptor instead.
+func (*RelatedLocation) Descriptor() ([]byte, []int) {
+	return file_reviewdog_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *RelatedLocation) GetMessage() string {
+	if x != nil {
+		return x.Message
+	}
+	return ""
+}
+
+func (x *RelatedLocation) GetLocation() *Location {
+	if x != nil {
+		return x.Location
+	}
+	return nil
+}
+
 // start: { line: 2, column: 1 }
 // end:   { line: 2, column: 4 }
 //
@@ -346,7 +414,7 @@ type Range struct {
 func (x *Range) Reset() {
 	*x = Range{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_reviewdog_proto_msgTypes[3]
+		mi := &file_reviewdog_proto_msgTypes[4]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -359,7 +427,7 @@ func (x *Range) String() string {
 func (*Range) ProtoMessage() {}
 
 func (x *Range) ProtoReflect() protoreflect.Message {
-	mi := &file_reviewdog_proto_msgTypes[3]
+	mi := &file_reviewdog_proto_msgTypes[4]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -372,7 +440,7 @@ func (x *Range) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Range.ProtoReflect.Descriptor instead.
 func (*Range) Descriptor() ([]byte, []int) {
-	return file_reviewdog_proto_rawDescGZIP(), []int{3}
+	return file_reviewdog_proto_rawDescGZIP(), []int{4}
 }
 
 func (x *Range) GetStart() *Position {
@@ -411,7 +479,7 @@ type Position struct {
 func (x *Position) Reset() {
 	*x = Position{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_reviewdog_proto_msgTypes[4]
+		mi := &file_reviewdog_proto_msgTypes[5]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -424,7 +492,7 @@ func (x *Position) String() string {
 func (*Position) ProtoMessage() {}
 
 func (x *Position) ProtoReflect() protoreflect.Message {
-	mi := &file_reviewdog_proto_msgTypes[4]
+	mi := &file_reviewdog_proto_msgTypes[5]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -437,7 +505,7 @@ func (x *Position) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Position.ProtoReflect.Descriptor instead.
 func (*Position) Descriptor() ([]byte, []int) {
-	return file_reviewdog_proto_rawDescGZIP(), []int{4}
+	return file_reviewdog_proto_rawDescGZIP(), []int{5}
 }
 
 func (x *Position) GetLine() int32 {
@@ -510,7 +578,7 @@ type Suggestion struct {
 func (x *Suggestion) Reset() {
 	*x = Suggestion{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_reviewdog_proto_msgTypes[5]
+		mi := &file_reviewdog_proto_msgTypes[6]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -523,7 +591,7 @@ func (x *Suggestion) String() string {
 func (*Suggestion) ProtoMessage() {}
 
 func (x *Suggestion) ProtoReflect() protoreflect.Message {
-	mi := &file_reviewdog_proto_msgTypes[5]
+	mi := &file_reviewdog_proto_msgTypes[6]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -536,7 +604,7 @@ func (x *Suggestion) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Suggestion.ProtoReflect.Descriptor instead.
 func (*Suggestion) Descriptor() ([]byte, []int) {
-	return file_reviewdog_proto_rawDescGZIP(), []int{5}
+	return file_reviewdog_proto_rawDescGZIP(), []int{6}
 }
 
 func (x *Suggestion) GetRange() *Range {
@@ -569,7 +637,7 @@ type Source struct {
 func (x *Source) Reset() {
 	*x = Source{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_reviewdog_proto_msgTypes[6]
+		mi := &file_reviewdog_proto_msgTypes[7]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -582,7 +650,7 @@ func (x *Source) String() string {
 func (*Source) ProtoMessage() {}
 
 func (x *Source) ProtoReflect() protoreflect.Message {
-	mi := &file_reviewdog_proto_msgTypes[6]
+	mi := &file_reviewdog_proto_msgTypes[7]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -595,7 +663,7 @@ func (x *Source) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Source.ProtoReflect.Descriptor instead.
 func (*Source) Descriptor() ([]byte, []int) {
-	return file_reviewdog_proto_rawDescGZIP(), []int{6}
+	return file_reviewdog_proto_rawDescGZIP(), []int{7}
 }
 
 func (x *Source) GetName() string {
@@ -627,7 +695,7 @@ type Code struct {
 func (x *Code) Reset() {
 	*x = Code{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_reviewdog_proto_msgTypes[7]
+		mi := &file_reviewdog_proto_msgTypes[8]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -640,7 +708,7 @@ func (x *Code) String() string {
 func (*Code) ProtoMessage() {}
 
 func (x *Code) ProtoReflect() protoreflect.Message {
-	mi := &file_reviewdog_proto_msgTypes[7]
+	mi := &file_reviewdog_proto_msgTypes[8]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -653,7 +721,7 @@ func (x *Code) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Code.ProtoReflect.Descriptor instead.
 func (*Code) Descriptor() ([]byte, []int) {
-	return file_reviewdog_proto_rawDescGZIP(), []int{7}
+	return file_reviewdog_proto_rawDescGZIP(), []int{8}
 }
 
 func (x *Code) GetValue() string {
@@ -686,7 +754,7 @@ var file_reviewdog_proto_rawDesc = []byte{
 	0x65, 0x12, 0x33, 0x0a, 0x08, 0x73, 0x65, 0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x18, 0x03, 0x20,
 	0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2e,
 	0x72, 0x64, 0x66, 0x2e, 0x53, 0x65, 0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x52, 0x08, 0x73, 0x65,
-	0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x22, 0xce, 0x02, 0x0a, 0x0a, 0x44, 0x69, 0x61, 0x67, 0x6e,
+	0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x22, 0x9b, 0x03, 0x0a, 0x0a, 0x44, 0x69, 0x61, 0x67, 0x6e,
 	0x6f, 0x73, 0x74, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
 	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12,
 	0x33, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28,
@@ -707,40 +775,51 @@ var file_reviewdog_proto_rawDesc = []byte{
 	0x6e, 0x52, 0x0b, 0x73, 0x75, 0x67, 0x67, 0x65, 0x73, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x27,
 	0x0a, 0x0f, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75,
 	0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61,
-	0x6c, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0x4a, 0x0a, 0x08, 0x4c, 0x6f, 0x63, 0x61, 0x74,
-	0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x2a, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65,
-	0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x64,
-	0x6f, 0x67, 0x2e, 0x72, 0x64, 0x66, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x05, 0x72, 0x61,
-	0x6e, 0x67, 0x65, 0x22, 0x61, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x2d, 0x0a, 0x05,
-	0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65,
-	0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2e, 0x72, 0x64, 0x66, 0x2e, 0x50, 0x6f, 0x73, 0x69,
-	0x74, 0x69, 0x6f, 0x6e, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x29, 0x0a, 0x03, 0x65,
-	0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x76, 0x69, 0x65,
-	0x77, 0x64, 0x6f, 0x67, 0x2e, 0x72, 0x64, 0x66, 0x2e, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f,
-	0x6e, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x22, 0x36, 0x0a, 0x08, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69,
-	0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05,
-	0x52, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e,
-	0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x22, 0x4c,
-	0x0a, 0x0a, 0x53, 0x75, 0x67, 0x67, 0x65, 0x73, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2a, 0x0a, 0x05,
-	0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x72, 0x65,
-	0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2e, 0x72, 0x64, 0x66, 0x2e, 0x52, 0x61, 0x6e, 0x67,
-	0x65, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74,
-	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x22, 0x2e, 0x0a, 0x06,
-	0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72,
-	0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x2e, 0x0a, 0x04,
-	0x43, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72,
-	0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x2a, 0x42, 0x0a, 0x08,
-	0x53, 0x65, 0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x10, 0x55, 0x4e, 0x4b, 0x4e,
-	0x4f, 0x57, 0x4e, 0x5f, 0x53, 0x45, 0x56, 0x45, 0x52, 0x49, 0x54, 0x59, 0x10, 0x00, 0x12, 0x09,
-	0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x57, 0x41, 0x52,
-	0x4e, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x03,
-	0x42, 0x2a, 0x5a, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x72,
-	0x65, 0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2f, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x64,
-	0x6f, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x72, 0x64, 0x66, 0x62, 0x06, 0x70, 0x72,
-	0x6f, 0x74, 0x6f, 0x33,
+	0x6c, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x4b, 0x0a, 0x11, 0x72, 0x65, 0x6c, 0x61, 0x74,
+	0x65, 0x64, 0x5f, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03,
+	0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2e, 0x72,
+	0x64, 0x66, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x52, 0x10, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x4c, 0x6f, 0x63, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x73, 0x22, 0x4a, 0x0a, 0x08, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+	0x70, 0x61, 0x74, 0x68, 0x12, 0x2a, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x03, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2e,
+	0x72, 0x64, 0x66, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65,
+	0x22, 0x60, 0x0a, 0x0f, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x4c, 0x6f, 0x63, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x33, 0x0a,
+	0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x17, 0x2e, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2e, 0x72, 0x64, 0x66, 0x2e,
+	0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x22, 0x61, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x2d, 0x0a, 0x05, 0x73,
+	0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x76,
+	0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2e, 0x72, 0x64, 0x66, 0x2e, 0x50, 0x6f, 0x73, 0x69, 0x74,
+	0x69, 0x6f, 0x6e, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x29, 0x0a, 0x03, 0x65, 0x6e,
+	0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77,
+	0x64, 0x6f, 0x67, 0x2e, 0x72, 0x64, 0x66, 0x2e, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e,
+	0x52, 0x03, 0x65, 0x6e, 0x64, 0x22, 0x36, 0x0a, 0x08, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f,
+	0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52,
+	0x04, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x22, 0x4c, 0x0a,
+	0x0a, 0x53, 0x75, 0x67, 0x67, 0x65, 0x73, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2a, 0x0a, 0x05, 0x72,
+	0x61, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x72, 0x65, 0x76,
+	0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2e, 0x72, 0x64, 0x66, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65,
+	0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78, 0x74, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x22, 0x2e, 0x0a, 0x06, 0x53,
+	0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x2e, 0x0a, 0x04, 0x43,
+	0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x2a, 0x42, 0x0a, 0x08, 0x53,
+	0x65, 0x76, 0x65, 0x72, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x10, 0x55, 0x4e, 0x4b, 0x4e, 0x4f,
+	0x57, 0x4e, 0x5f, 0x53, 0x45, 0x56, 0x45, 0x52, 0x49, 0x54, 0x59, 0x10, 0x00, 0x12, 0x09, 0x0a,
+	0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x57, 0x41, 0x52, 0x4e,
+	0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x03, 0x42,
+	0x2a, 0x5a, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x72, 0x65,
+	0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2f, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x64, 0x6f,
+	0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x72, 0x64, 0x66, 0x62, 0x06, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x33,
 }
 
 var (
@@ -756,36 +835,39 @@ func file_reviewdog_proto_rawDescGZIP() []byte {
 }
 
 var file_reviewdog_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
-var file_reviewdog_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+var file_reviewdog_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
 var file_reviewdog_proto_goTypes = []any{
 	(Severity)(0),            // 0: reviewdog.rdf.Severity
 	(*DiagnosticResult)(nil), // 1: reviewdog.rdf.DiagnosticResult
 	(*Diagnostic)(nil),       // 2: reviewdog.rdf.Diagnostic
 	(*Location)(nil),         // 3: reviewdog.rdf.Location
-	(*Range)(nil),            // 4: reviewdog.rdf.Range
-	(*Position)(nil),         // 5: reviewdog.rdf.Position
-	(*Suggestion)(nil),       // 6: reviewdog.rdf.Suggestion
-	(*Source)(nil),           // 7: reviewdog.rdf.Source
-	(*Code)(nil),             // 8: reviewdog.rdf.Code
+	(*RelatedLocation)(nil),  // 4: reviewdog.rdf.RelatedLocation
+	(*Range)(nil),            // 5: reviewdog.rdf.Range
+	(*Position)(nil),         // 6: reviewdog.rdf.Position
+	(*Suggestion)(nil),       // 7: reviewdog.rdf.Suggestion
+	(*Source)(nil),           // 8: reviewdog.rdf.Source
+	(*Code)(nil),             // 9: reviewdog.rdf.Code
 }
 var file_reviewdog_proto_depIdxs = []int32{
 	2,  // 0: reviewdog.rdf.DiagnosticResult.diagnostics:type_name -> reviewdog.rdf.Diagnostic
-	7,  // 1: reviewdog.rdf.DiagnosticResult.source:type_name -> reviewdog.rdf.Source
+	8,  // 1: reviewdog.rdf.DiagnosticResult.source:type_name -> reviewdog.rdf.Source
 	0,  // 2: reviewdog.rdf.DiagnosticResult.severity:type_name -> reviewdog.rdf.Severity
 	3,  // 3: reviewdog.rdf.Diagnostic.location:type_name -> reviewdog.rdf.Location
 	0,  // 4: reviewdog.rdf.Diagnostic.severity:type_name -> reviewdog.rdf.Severity
-	7,  // 5: reviewdog.rdf.Diagnostic.source:type_name -> reviewdog.rdf.Source
-	8,  // 6: reviewdog.rdf.Diagnostic.code:type_name -> reviewdog.rdf.Code
-	6,  // 7: reviewdog.rdf.Diagnostic.suggestions:type_name -> reviewdog.rdf.Suggestion
-	4,  // 8: reviewdog.rdf.Location.range:type_name -> reviewdog.rdf.Range
-	5,  // 9: reviewdog.rdf.Range.start:type_name -> reviewdog.rdf.Position
-	5,  // 10: reviewdog.rdf.Range.end:type_name -> reviewdog.rdf.Position
-	4,  // 11: reviewdog.rdf.Suggestion.range:type_name -> reviewdog.rdf.Range
-	12, // [12:12] is the sub-list for method output_type
-	12, // [12:12] is the sub-list for method input_type
-	12, // [12:12] is the sub-list for extension type_name
-	12, // [12:12] is the sub-list for extension extendee
-	0,  // [0:12] is the sub-list for field type_name
+	8,  // 5: reviewdog.rdf.Diagnostic.source:type_name -> reviewdog.rdf.Source
+	9,  // 6: reviewdog.rdf.Diagnostic.code:type_name -> reviewdog.rdf.Code
+	7,  // 7: reviewdog.rdf.Diagnostic.suggestions:type_name -> reviewdog.rdf.Suggestion
+	4,  // 8: reviewdog.rdf.Diagnostic.related_locations:type_name -> reviewdog.rdf.RelatedLocation
+	5,  // 9: reviewdog.rdf.Location.range:type_name -> reviewdog.rdf.Range
+	3,  // 10: reviewdog.rdf.RelatedLocation.location:type_name -> reviewdog.rdf.Location
+	6,  // 11: reviewdog.rdf.Range.start:type_name -> reviewdog.rdf.Position
+	6,  // 12: reviewdog.rdf.Range.end:type_name -> reviewdog.rdf.Position
+	5,  // 13: reviewdog.rdf.Suggestion.range:type_name -> reviewdog.rdf.Range
+	14, // [14:14] is the sub-list for method output_type
+	14, // [14:14] is the sub-list for method input_type
+	14, // [14:14] is the sub-list for extension type_name
+	14, // [14:14] is the sub-list for extension extendee
+	0,  // [0:14] is the sub-list for field type_name
 }
 
 func init() { file_reviewdog_proto_init() }
@@ -831,7 +913,7 @@ func file_reviewdog_proto_init() {
 			}
 		}
 		file_reviewdog_proto_msgTypes[3].Exporter = func(v any, i int) any {
-			switch v := v.(*Range); i {
+			switch v := v.(*RelatedLocation); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -843,7 +925,7 @@ func file_reviewdog_proto_init() {
 			}
 		}
 		file_reviewdog_proto_msgTypes[4].Exporter = func(v any, i int) any {
-			switch v := v.(*Position); i {
+			switch v := v.(*Range); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -855,7 +937,7 @@ func file_reviewdog_proto_init() {
 			}
 		}
 		file_reviewdog_proto_msgTypes[5].Exporter = func(v any, i int) any {
-			switch v := v.(*Suggestion); i {
+			switch v := v.(*Position); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -867,7 +949,7 @@ func file_reviewdog_proto_init() {
 			}
 		}
 		file_reviewdog_proto_msgTypes[6].Exporter = func(v any, i int) any {
-			switch v := v.(*Source); i {
+			switch v := v.(*Suggestion); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -879,6 +961,18 @@ func file_reviewdog_proto_init() {
 			}
 		}
 		file_reviewdog_proto_msgTypes[7].Exporter = func(v any, i int) any {
+			switch v := v.(*Source); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_reviewdog_proto_msgTypes[8].Exporter = func(v any, i int) any {
 			switch v := v.(*Code); i {
 			case 0:
 				return &v.state
@@ -897,7 +991,7 @@ func file_reviewdog_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_reviewdog_proto_rawDesc,
 			NumEnums:      1,
-			NumMessages:   8,
+			NumMessages:   9,
 			NumExtensions: 0,
 			NumServices:   0,
 		},
diff --git a/proto/rdf/reviewdog.proto b/proto/rdf/reviewdog.proto
index c3af64df..305d4c64 100644
--- a/proto/rdf/reviewdog.proto
+++ b/proto/rdf/reviewdog.proto
@@ -64,6 +64,10 @@ message Diagnostic {
   // diagnostic.
   // Optional.
   string original_output = 7;
+
+  // Related locations for this diagnostic.
+  // Optional.
+  repeated RelatedLocation related_locations = 8;
 }
 
 enum Severity {
@@ -82,6 +86,15 @@ message Location {
   Range range = 3;
 }
 
+message RelatedLocation {
+  // Explanation of this related location.
+  // Optional.
+  string message = 1;
+
+  // Required.
+  Location location = 2;
+}
+
 // A range in a text document expressed as start and end positions.
 
 // The end position is *exclusive*. It might be a bit unnatural for you or for
diff --git a/service/github/github.go b/service/github/github.go
index e748b0ca..fe40c76c 100644
--- a/service/github/github.go
+++ b/service/github/github.go
@@ -16,6 +16,7 @@ import (
 
 	"github.com/reviewdog/reviewdog"
 	"github.com/reviewdog/reviewdog/cienv"
+	"github.com/reviewdog/reviewdog/pathutil"
 	"github.com/reviewdog/reviewdog/proto/rdf"
 	"github.com/reviewdog/reviewdog/service/commentutil"
 	"github.com/reviewdog/reviewdog/service/github/githubutils"
@@ -130,6 +131,11 @@ func (g *PullRequest) postAsReviewComment(ctx context.Context) error {
 	rawComments := make([]*reviewdog.Comment, 0, len(postComments))
 	reviewComments := make([]*github.DraftReviewComment, 0, len(postComments))
 	remaining := make([]*reviewdog.Comment, 0)
+	repoBaseHTMLURLForRelatedLoc := ""
+	rootPath, err := serviceutil.GetGitRoot()
+	if err != nil {
+		return err
+	}
 	for _, c := range postComments {
 		if !c.Result.InDiffContext {
 			// GitHub Review API cannot report results outside diff. If it's running
@@ -141,7 +147,14 @@ func (g *PullRequest) postAsReviewComment(ctx context.Context) error {
 			}
 			continue
 		}
-		body := buildBody(c)
+		if repoBaseHTMLURLForRelatedLoc == "" && len(c.Result.Diagnostic.GetRelatedLocations()) > 0 {
+			repo, _, err := g.cli.Repositories.Get(ctx, g.owner, g.repo)
+			if err != nil {
+				return err
+			}
+			repoBaseHTMLURLForRelatedLoc = repo.GetHTMLURL() + "/blob/" + g.sha
+		}
+		body := buildBody(c, repoBaseHTMLURLForRelatedLoc, rootPath)
 		if g.postedcs.IsPosted(c, githubCommentLine(c), body) {
 			// it's already posted. skip it.
 			continue
@@ -390,11 +403,23 @@ func listAllPullRequestsComments(ctx context.Context, cli *github.Client,
 	return append(comments, restComments...), nil
 }
 
-func buildBody(c *reviewdog.Comment) string {
+func buildBody(c *reviewdog.Comment, baseRelatedLocURL string, gitRootPath string) string {
 	cbody := commentutil.MarkdownComment(c)
 	if suggestion := buildSuggestions(c); suggestion != "" {
 		cbody += "\n" + suggestion
 	}
+	for _, relatedLoc := range c.Result.Diagnostic.GetRelatedLocations() {
+		loc := relatedLoc.GetLocation()
+		if loc.GetPath() == "" || loc.GetRange().GetStart().GetLine() == 0 {
+			continue
+		}
+		relPath := pathutil.NormalizePath(loc.GetPath(), gitRootPath, "")
+		relatedURL := fmt.Sprintf("%s/%s#L%d", baseRelatedLocURL, relPath, loc.GetRange().GetStart().GetLine())
+		if endLine := loc.GetRange().GetEnd().GetLine(); endLine > 0 {
+			relatedURL += fmt.Sprintf("-L%d", endLine)
+		}
+		cbody += "\n<hr>\n\n" + relatedLoc.GetMessage() + "\n" + relatedURL
+	}
 	return cbody
 }
 


Test patch:
diff --git a/_testdata/custom.txt b/_testdata/custom.txt
index 93477091..5a8cbdd6 100644
--- a/_testdata/custom.txt
+++ b/_testdata/custom.txt
@@ -15,3 +15,5 @@ haya14busa
 function f()
 {
 }
+
+RelatedLoc
diff --git a/_testdata/custom_rdjson.json b/_testdata/custom_rdjson.json
index 203001fa..8ab744db 100644
--- a/_testdata/custom_rdjson.json
+++ b/_testdata/custom_rdjson.json
@@ -201,6 +201,54 @@
           }
         }
       ]
+    },
+    {
+      "message": "test related location",
+      "location": {
+        "path": "_testdata/custom.txt",
+        "range": {
+          "start": {
+            "line": 19
+          }
+        }
+      },
+      "suggestions": [
+        {
+          "text": "test suggestion w/ related_locations",
+          "range": {
+            "start": {
+              "line": 19
+            }
+          }
+        }
+      ],
+      "related_locations": [
+        {
+          "message": "related loc 1",
+          "location": {
+            "path": "_testdata/custom.txt",
+            "range": {
+              "start": {
+                "line": 9
+              }
+            }
+          }
+        },
+        {
+          "message": "related loc 2 (different file)",
+          "location": {
+            "path": "_testdata/suggestions.txt",
+            "range": {
+              "start": {
+                "line": 6
+              },
+              "end": {
+                "line": 8
+              }
+            }
+          }
+        }
+      ]
     }
   ]
 }
diff --git a/service/github/github_test.go b/service/github/github_test.go
index 17bcb746..3be394cb 100644
--- a/service/github/github_test.go
+++ b/service/github/github_test.go
@@ -7,6 +7,7 @@ import (
 	"net/http/httptest"
 	"net/url"
 	"os"
+	"path/filepath"
 	"strings"
 	"testing"
 
@@ -170,6 +171,7 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) {
 
 	listCommentsAPICalled := 0
 	postCommentsAPICalled := 0
+	repoCommentsAPICalled := 0
 	mux := http.NewServeMux()
 	mux.HandleFunc("/repos/o/r/pulls/14/comments", func(w http.ResponseWriter, r *http.Request) {
 		switch r.Method {
@@ -463,11 +465,37 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) {
 					"``````",
 				}, "\n") + "\n"),
 			},
+			{
+				Path:      github.String("reviewdog.go"),
+				Side:      github.String("RIGHT"),
+				StartSide: github.String("RIGHT"),
+				StartLine: github.Int(15),
+				Line:      github.Int(16),
+				Body: github.String(commentutil.BodyPrefix + strings.Join([]string{
+					"related location test",
+					"<hr>",
+					"",
+					"related loc test",
+					"https://test/repo/path/blob/sha/reviewdog.go#L14-L16",
+					"<hr>",
+					"",
+					"related loc test (2)",
+					"https://test/repo/path/blob/sha/service/github/reviewdog2.go#L14",
+				}, "\n")),
+			},
 		}
 		if diff := pretty.Compare(want, req.Comments); diff != "" {
 			t.Errorf("req.Comments diff: (-got +want)\n%s", diff)
 		}
 	})
+	mux.HandleFunc("/repos/o/r", func(w http.ResponseWriter, r *http.Request) {
+		repoCommentsAPICalled++
+		if err := json.NewEncoder(w).Encode(&github.Repository{
+			HTMLURL: github.String("https://test/repo/path"),
+		}); err != nil {
+			t.Fatal(err)
+		}
+	})
 	ts := httptest.NewServer(mux)
 	defer ts.Close()
 
@@ -1147,6 +1175,53 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) {
 				InDiffContext: true,
 			},
 		},
+		{
+			Result: &filter.FilteredDiagnostic{
+				Diagnostic: &rdf.Diagnostic{
+					Location: &rdf.Location{
+						Path: "reviewdog.go",
+						Range: &rdf.Range{
+							Start: &rdf.Position{
+								Line: 15,
+							},
+							End: &rdf.Position{
+								Line: 16,
+							},
+						},
+					},
+					RelatedLocations: []*rdf.RelatedLocation{
+						{
+							Location: &rdf.Location{
+								Path: "reviewdog.go",
+								Range: &rdf.Range{
+									Start: &rdf.Position{
+										Line: 14,
+									},
+									End: &rdf.Position{
+										Line: 16,
+									},
+								},
+							},
+							Message: "related loc test",
+						},
+						{
+							Location: &rdf.Location{
+								Path: filepath.Join(cwd, "reviewdog2.go"),
+								Range: &rdf.Range{
+									Start: &rdf.Position{
+										Line: 14,
+									},
+								},
+							},
+							Message: "related loc test (2)",
+						},
+					},
+					Message: "related location test",
+				},
+				InDiffFile:    true,
+				InDiffContext: true,
+			},
+		},
 	}
 	for _, c := range comments {
 		if err := g.Post(context.Background(), c); err != nil {