Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/errors/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ func formatGitHubValidationDetail(validationErr github.Error) string {

func sanitizeGitHubValidationText(value string) string {
// Tool errors are plain text; keep quoted branch patterns readable.
sanitized := strings.ReplaceAll(sanitize.Sanitize(value), "'", "'")
sanitized := sanitize.Title(value)
return strings.Join(strings.Fields(sanitized), " ")
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/github/discussions.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ type WithCategoryNoOrder struct {
func fragmentToDiscussion(fragment NodeFragment) *github.Discussion {
return &github.Discussion{
Number: github.Ptr(int(fragment.Number)),
Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))),
Title: github.Ptr(sanitize.Title(string(fragment.Title))),
HTMLURL: github.Ptr(string(fragment.URL)),
CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time},
UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time},
Expand Down Expand Up @@ -361,7 +361,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool {
// like ListDiscussions and GetDiscussionComments).
response := map[string]any{
"number": int(d.Number),
"title": sanitize.Sanitize(string(d.Title)),
"title": sanitize.Title(string(d.Title)),
"body": sanitize.Sanitize(string(d.Body)),
"url": string(d.URL),
"closed": bool(d.Closed),
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -1194,7 +1194,7 @@ func GetIssueParent(ctx context.Context, client *githubv4.Client, deps ToolDepen
return MarshalledTextResult(map[string]any{
"parent": map[string]any{
"number": int(parent.Number),
"title": sanitize.Sanitize(string(parent.Title)),
"title": sanitize.Title(string(parent.Title)),
"state": string(parent.State),
"url": string(parent.URL),
"repository": string(parent.Repository.NameWithOwner),
Expand Down Expand Up @@ -1995,7 +1995,7 @@ func sanitizeIssueTitleAndBody(issue *github.Issue) {
return
}
if issue.Title != nil {
issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title))
issue.Title = github.Ptr(sanitize.Title(*issue.Title))
}
if issue.Body != nil {
issue.Body = github.Ptr(sanitize.Sanitize(*issue.Body))
Expand Down
22 changes: 11 additions & 11 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ type MinimalPullRequestRef struct {
func newMinimalPullRequestRef(number int, title, state, url, repository string) MinimalPullRequestRef {
return MinimalPullRequestRef{
Number: number,
Title: sanitize.Sanitize(title),
Title: sanitize.Title(title),
State: state,
URL: url,
Repository: repository,
Expand All @@ -646,7 +646,7 @@ type MinimalIssueRef struct {
func newMinimalIssueRef(number int, title, state, url, repository string) MinimalIssueRef {
return MinimalIssueRef{
Number: number,
Title: sanitize.Sanitize(title),
Title: sanitize.Title(title),
State: state,
URL: url,
Repository: repository,
Expand Down Expand Up @@ -814,7 +814,7 @@ func convertToMinimalPullRequestReview(review *github.PullRequestReview) Minimal
func convertToMinimalIssue(issue *github.Issue) MinimalIssue {
m := MinimalIssue{
Number: issue.GetNumber(),
Title: sanitize.Sanitize(issue.GetTitle()),
Title: sanitize.Title(issue.GetTitle()),
Body: sanitize.Sanitize(issue.GetBody()),
State: issue.GetState(),
StateReason: issue.GetStateReason(),
Expand Down Expand Up @@ -925,7 +925,7 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue {
func fragmentWithoutFieldValuesToMinimalIssue(fragment issueFragmentWithoutFieldValues) MinimalIssue {
m := MinimalIssue{
Number: int(fragment.Number),
Title: sanitize.Sanitize(string(fragment.Title)),
Title: sanitize.Title(string(fragment.Title)),
Body: sanitize.Sanitize(string(fragment.Body)),
State: string(fragment.State),
Comments: int(fragment.Comments.TotalCount),
Expand Down Expand Up @@ -1084,7 +1084,7 @@ func convertToMinimalFileContentResponse(resp *github.RepositoryContentResponse)
func convertToMinimalPullRequest(pr *github.PullRequest) MinimalPullRequest {
m := MinimalPullRequest{
Number: pr.GetNumber(),
Title: sanitize.Sanitize(pr.GetTitle()),
Title: sanitize.Title(pr.GetTitle()),
Body: sanitize.Sanitize(pr.GetBody()),
State: pr.GetState(),
Draft: pr.GetDraft(),
Expand Down Expand Up @@ -1279,7 +1279,7 @@ func convertIssueToMinimalProjectItemContent(issue *github.Issue) *MinimalProjec
ID: issue.GetID(),
NodeID: issue.GetNodeID(),
Number: issue.GetNumber(),
Title: sanitize.Sanitize(issue.GetTitle()),
Title: sanitize.Title(issue.GetTitle()),
State: issue.GetState(),
StateReason: issue.GetStateReason(),
HTMLURL: issue.GetHTMLURL(),
Expand Down Expand Up @@ -1316,7 +1316,7 @@ func convertPullRequestToMinimalProjectItemContent(pr *github.PullRequest) *Mini
ID: pr.GetID(),
NodeID: pr.GetNodeID(),
Number: pr.GetNumber(),
Title: sanitize.Sanitize(pr.GetTitle()),
Title: sanitize.Title(pr.GetTitle()),
State: pr.GetState(),
HTMLURL: pr.GetHTMLURL(),
Repository: pullRequestRepositoryFullName(pr),
Expand Down Expand Up @@ -1353,7 +1353,7 @@ func convertDraftIssueToMinimalProjectItemContent(draftIssue *github.ProjectV2Dr
m := &MinimalProjectItemContent{
ID: draftIssue.GetID(),
NodeID: draftIssue.GetNodeID(),
Title: sanitize.Sanitize(draftIssue.GetTitle()),
Title: sanitize.Title(draftIssue.GetTitle()),
CreatedAt: formatProjectTimestamp(draftIssue.CreatedAt),
UpdatedAt: formatProjectTimestamp(draftIssue.UpdatedAt),
}
Expand Down Expand Up @@ -1612,7 +1612,7 @@ func minimalProjectPullRequestRefFromPullRequest(pr *github.PullRequest) minimal
}
return minimalProjectPullRequestRef{
Number: pr.GetNumber(),
Title: sanitize.Sanitize(pr.GetTitle()),
Title: sanitize.Title(pr.GetTitle()),
State: pr.GetState(),
HTMLURL: pr.GetHTMLURL(),
Repository: pullRequestRepositoryFullName(pr),
Expand All @@ -1634,7 +1634,7 @@ func minimalProjectPullRequestRefFromMap(value map[string]any) minimalProjectPul

return minimalProjectPullRequestRef{
Number: intFromAny(value["number"]),
Title: sanitize.Sanitize(stringFromMap(value, "title")),
Title: sanitize.Title(stringFromMap(value, "title")),
State: stringFromMap(value, "state"),
HTMLURL: htmlURL,
Repository: repository,
Expand Down Expand Up @@ -2038,7 +2038,7 @@ func convertToMinimalRelease(release *github.RepositoryRelease) MinimalRelease {
m := MinimalRelease{
ID: release.GetID(),
TagName: release.GetTagName(),
Name: sanitize.Sanitize(release.GetName()),
Name: sanitize.Title(release.GetName()),
Body: sanitize.Sanitize(release.GetBody()),
HTMLURL: release.GetHTMLURL(),
Prerelease: release.GetPrerelease(),
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -2981,7 +2981,7 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool {
SHA: sha,
// Sanitized after truncation so the headline is cut at the author's real
// first line break rather than one introduced by sanitization.
MessageHeadline: sanitize.Sanitize(headline),
MessageHeadline: sanitize.Title(headline),
CommittedDate: r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z"),
Author: BlameAuthor{
Name: string(r.Commit.Author.Name),
Expand Down
105 changes: 105 additions & 0 deletions pkg/github/sanitize_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,111 @@ func Test_SearchIssueResult_SanitizesTitleAndBody(t *testing.T) {
assert.Equal(t, sanitizedText, decoded.Body)
}

func Test_MinimalConverters_TitlePreservesVisibleText(t *testing.T) {
title := "[bug] can't add a connection to toolkits in desktop app"

tests := []struct {
name string
got func() string
}{
{
name: "issue title (REST)",
got: func() string {
return convertToMinimalIssue(&github.Issue{Title: github.Ptr(title)}).Title
},
},
{
name: "issue title (GraphQL)",
got: func() string {
return fragmentWithoutFieldValuesToMinimalIssue(issueFragmentWithoutFieldValues{
Title: githubv4.String(title),
}).Title
},
},
{
name: "pull request title",
got: func() string {
return convertToMinimalPullRequest(&github.PullRequest{Title: github.Ptr(title)}).Title
},
},
{
name: "release name",
got: func() string {
return convertToMinimalRelease(&github.RepositoryRelease{Name: github.Ptr(title)}).Name
},
},
{
name: "project item content title (issue)",
got: func() string {
return convertIssueToMinimalProjectItemContent(&github.Issue{Title: github.Ptr(title)}).Title
},
},
{
name: "project item content title (pull request)",
got: func() string {
return convertPullRequestToMinimalProjectItemContent(&github.PullRequest{Title: github.Ptr(title)}).Title
},
},
{
name: "project item content title (draft issue)",
got: func() string {
return convertDraftIssueToMinimalProjectItemContent(&github.ProjectV2DraftIssue{Title: github.Ptr(title)}).Title
},
},
{
name: "project pull request ref title (from *github.PullRequest)",
got: func() string {
return minimalProjectPullRequestRefFromPullRequest(&github.PullRequest{Title: github.Ptr(title)}).Title
},
},
{
name: "project pull request ref title (from map)",
got: func() string {
return minimalProjectPullRequestRefFromMap(map[string]any{"title": title}).Title
},
},
{
name: "issue ref title (shared constructor)",
got: func() string {
return newMinimalIssueRef(1, title, "OPEN", "https://github.com/o/r/issues/1", "o/r").Title
},
},
{
name: "pull request ref title (shared constructor)",
got: func() string {
return newMinimalPullRequestRef(1, title, "OPEN", "https://github.com/o/r/pull/1", "o/r").Title
},
},
{
name: "issue dependency ref title",
got: func() string {
return issueToDependencyRef(&github.Issue{Title: github.Ptr(title)}).Title
},
},
{
name: "discussion title",
got: func() string {
discussion := fragmentToDiscussion(NodeFragment{Title: githubv4.String(title)})
return discussion.GetTitle()
},
},
{
name: "search issue result title",
got: func() string {
issue := &github.Issue{Title: github.Ptr(title)}
sanitizeIssueTitleAndBody(issue)
return issue.GetTitle()
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, title, tt.got())
})
}
}

// Test_SanitizeIssueTitleAndBody exercises the shared helper directly, including its nil-safety,
// since it backs both search_issues and search_pull_requests.
func Test_SanitizeIssueTitleAndBody(t *testing.T) {
Expand Down
39 changes: 39 additions & 0 deletions pkg/sanitize/sanitize.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,45 @@ func Sanitize(input string) string {
return FilterCodeFenceMetadata(FilterInvisibleCharacters(normalized))
}

// Title sanitizes short metadata fields such as issue and pull request titles.
// It applies the same HTML and invisible-character policy as Sanitize, then
// restores the punctuation that policy HTML-escapes so visible characters
// remain as themselves (for example, "can't" instead of "can't").
//
// Angle brackets stay escaped. Decoding < / > would reconstitute markup
// from entity-encoded tags, including nested < payloads.
func Title(input string) string {
return restoreVisiblePunctuation(Sanitize(input))
}

// visiblePunctuationUnescaper inverts html.EscapeString for apostrophe, quote,
// and ampersand only. It must not include < or >.
var visiblePunctuationUnescaper = strings.NewReplacer(
"'", "'",
""", `"`,
""", `"`,
"'", "'",
"&", "&",
)

func restoreVisiblePunctuation(input string) string {
if !strings.Contains(input, "&") {
return input
}
out := input
// Peel stacked & prefixes (' → ' → ') without ever
// turning < / > into angle brackets. Each Replace shortens the
// string or is a no-op, so this is bounded by len(input).
for range len(input) {
next := visiblePunctuationUnescaper.Replace(out)
if next == out {
return out
}
out = next
}
return out
}

// FilterInvisibleCharacters removes invisible or control characters that should not appear
// in user-facing titles or bodies. This includes:
// - Unicode tag characters: U+E0001, U+E0020–U+E007F
Expand Down
Loading