From 026c405a06a773e2b87c126f69f47c01c86ec51c Mon Sep 17 00:00:00 2001 From: Louis-Michel Couture Date: Fri, 28 Aug 2026 09:59:16 -0400 Subject: [PATCH] Add issue event history to issue_read behind a feature flag Adds three read methods to issue_read, gated behind the new issue_events feature flag: - get_events - the issue's event feed (labeled, assigned, closed, ...) - get_timeline - the full timeline, a superset that also carries comments, commits, reviews and cross-references - get_event - a single event addressed by its own event_id Behind the flag because the REST payloads are large and the added schema is not free: a single timeline page measured 157KB raw. Both feeds are trimmed to minimal types (MinimalIssueEvent, MinimalTimelineItem) that keep the event semantics and sanitize every user-authored field, in line with the surrounding issue reads, bringing that page down to 21KB. Trimming keeps the fields that carry event identity rather than everything the upstream types can hold: - The issue is kept as a compact MinimalIssueRef. Only get_event sends one, and since it is addressed by event id that ref is the only thing naming the issue an event belongs to. - Timeline entries carry commit_repository, derived from the upstream commit_url. A referenced or force-push entry frequently points at a commit outside the issue's own repo (a fork branch, or an unrelated repo whose commit message mentioned the issue), so a bare commit_id cannot be resolved. - committed entries carry the commit's author and committer. Those entries have no actor and no user, so without them the commit history is anonymous. They are the commit object's name/email rather than GitHub logins, which is why lockdown still cannot verify them. - review_dismissed events carry the dismissed review's state, id, dismissal message and commit, which the event name alone does not convey. - Commit ancestry (Timeline.Parents) is dropped: a timeline is read to follow the issue, not to walk commit history. The ungated issue_read is untouched: same schema (issue_read.snap is unchanged), and the handler rejects the three methods as unknown when the flag is off. issue_number leaves the schema-level required list on the gated variant only, since get_event has no issue number to supply; the handler enforces it per method. Lockdown mode follows the existing split: get_events and get_timeline filter out entries whose author lacks push access, while get_event refuses the read outright, as issue_read:get does. Attribution consults both Actor and User, since commented and reviewed entries name their author in User and leave Actor empty. Refs #984, #985 Closes #211 Co-authored-by: Artem Saveliev Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 3 + docs/feature-flags.md | 22 + .../issue_read_ff_issue_events.snap | 60 ++ pkg/github/feature_flags.go | 8 + pkg/github/helper_test.go | 3 + pkg/github/issues.go | 265 +++++- pkg/github/issues_events_test.go | 787 ++++++++++++++++++ pkg/github/lockdown.go | 1 + pkg/github/minimal_types.go | 273 ++++++ pkg/github/tools.go | 1 + 10 files changed, 1409 insertions(+), 14 deletions(-) create mode 100644 pkg/github/__toolsnaps__/issue_read_ff_issue_events.snap create mode 100644 pkg/github/issues_events_test.go diff --git a/README.md b/README.md index 145281bcdb..366c1d1b36 100644 --- a/README.md +++ b/README.md @@ -1601,6 +1601,7 @@ The behavior of lockdown mode depends on the tool invoked. Following tools will return an error when the author lacks the push access: - `issue_read:get` +- `issue_read:get_event` - `pull_request_read:get` - `pull_request_read:get_diff` - `pull_request_read:get_files` @@ -1610,6 +1611,8 @@ Following tools will filter out content from users lacking the push access: - `issue_read:get_comments` - `issue_read:get_sub_issues` +- `issue_read:get_events` +- `issue_read:get_timeline` - `pull_request_read:get_comments` - `pull_request_read:get_review_comments` - `pull_request_read:get_reviews` diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 0ed3f9dc0e..f666a6ddf5 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -354,6 +354,28 @@ runtime behavior (such as output formatting) won't appear here. - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository (string, required) +### `issue_events` + +- **issue_read** - Get issue details + - **OAuth Challenge Scopes**: `repo` + - `event_id`: The ID of the issue event. Required for, and only used by, the get_event method. (number, optional) + - `issue_number`: The number of the issue. Required for every method except get_event. (number, optional) + - `method`: The read operation to perform on a single issue. + Options are: + 1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries, and `closed_by_pull_requests` summarizes the pull requests configured to close the issue as `total_count` plus up to 5 `references`. + 2. get_comments - Get issue comments. + 3. get_sub_issues - Get sub-issues (children) of the issue. + 4. get_parent - Get the parent issue, if this issue is a sub-issue of another. + 5. get_labels - Get labels assigned to the issue. + 6. get_events - Get the issue's event history: labeled, assigned, closed, renamed, referenced and so on. + 7. get_timeline - Get the issue's timeline. A superset of get_events that also includes comments, commits and reviews, so prefer it when you need the full narrative and get_events when you only need state changes. + 8. get_event - Get a single issue event by its `event_id`. Takes `event_id` instead of `issue_number`. + (string, required) + - `owner`: The owner of the repository (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: The name of the repository (string, required) + ### `thread_resolution_reason` - **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews diff --git a/pkg/github/__toolsnaps__/issue_read_ff_issue_events.snap b/pkg/github/__toolsnaps__/issue_read_ff_issue_events.snap new file mode 100644 index 0000000000..d0d22b4723 --- /dev/null +++ b/pkg/github/__toolsnaps__/issue_read_ff_issue_events.snap @@ -0,0 +1,60 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get issue details" + }, + "description": "Get information about a specific issue in a GitHub repository.", + "inputSchema": { + "properties": { + "event_id": { + "description": "The ID of the issue event. Required for, and only used by, the get_event method.", + "type": "number" + }, + "issue_number": { + "description": "The number of the issue. Required for every method except get_event.", + "type": "number" + }, + "method": { + "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries, and `closed_by_pull_requests` summarizes the pull requests configured to close the issue as `total_count` plus up to 5 `references`.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues (children) of the issue.\n4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n5. get_labels - Get labels assigned to the issue.\n6. get_events - Get the issue's event history: labeled, assigned, closed, renamed, referenced and so on.\n7. get_timeline - Get the issue's timeline. A superset of get_events that also includes comments, commits and reviews, so prefer it when you need the full narrative and get_events when you only need state changes.\n8. get_event - Get a single issue event by its `event_id`. Takes `event_id` instead of `issue_number`.\n", + "enum": [ + "get", + "get_comments", + "get_sub_issues", + "get_parent", + "get_labels", + "get_events", + "get_timeline", + "get_event" + ], + "type": "string" + }, + "owner": { + "description": "The owner of the repository", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "The name of the repository", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" + }, + "name": "issue_read" +} \ No newline at end of file diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 27202c5c83..0fb0e80e99 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -34,6 +34,13 @@ const FeatureFlagIssueDependencies = "issue_dependencies" // opt-in. const FeatureFlagDuplicateDetection = "duplicate_detection" +// FeatureFlagIssueEvents is the feature flag name for the issue event history +// methods on issue_read (get_events, get_timeline, get_event), which expose an +// issue's event feed and timeline. It is gated so the extra methods and the +// event_id parameter are not advertised in the default issue_read schema, +// keeping the fixed tool-schema cost small unless explicitly opted in. +const FeatureFlagIssueEvents = "issue_events" + // FeatureFlagThreadResolutionReason exposes resolution reasons for Copilot review threads. const FeatureFlagThreadResolutionReason = "thread_resolution_reason" @@ -51,6 +58,7 @@ var AllowedFeatureFlags = []string{ FeatureFlagFileBlame, FeatureFlagIssueDependencies, FeatureFlagDuplicateDetection, + FeatureFlagIssueEvents, FeatureFlagThreadResolutionReason, } diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 5fc541d45d..25d39c463f 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -62,6 +62,9 @@ const ( GetReposIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}" GetReposIssuesCommentByOwnerByRepoByCommentID = "GET /repos/{owner}/{repo}/issues/comments/{comment_id}" GetReposIssuesCommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments" + GetReposIssuesEventsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/events" + GetReposIssuesTimelineByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/timeline" + GetReposIssuesEventByOwnerByRepoByEventID = "GET /repos/{owner}/{repo}/issues/events/{event_id}" PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues" PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments" PostReposIssuesReactionsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions" diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 9b6ee5da6b..c47f704b69 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "slices" "strconv" "strings" "time" @@ -790,19 +791,53 @@ func isUnsupportedListIssuesIssueFieldsError(err error) bool { // IssueRead creates a tool to get details of a specific issue in a GitHub repository. func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { + st := issueRead(t, false) + st.FeatureFlagDisable = []string{FeatureFlagIssueEvents} + return st +} + +// IssueReadWithEvents creates the feature-gated issue_read variant that also exposes the +// issue's event history: get_events, get_timeline and get_event. +func IssueReadWithEvents(t translations.TranslationHelperFunc) inventory.ServerTool { + st := issueRead(t, true) + st.FeatureFlagEnable = FeatureFlagIssueEvents + return st +} + +// issueRead builds the issue_read tool. When withEvents is false the schema and behavior are +// exactly the ungated tool. +func issueRead(t translations.TranslationHelperFunc, withEvents bool) inventory.ServerTool { + methodDescription := "The read operation to perform on a single issue.\n" + + "Options are:\n" + + "1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries, and `closed_by_pull_requests` summarizes the pull requests configured to close the issue as `total_count` plus up to 5 `references`.\n" + + "2. get_comments - Get issue comments.\n" + + "3. get_sub_issues - Get sub-issues (children) of the issue.\n" + + "4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n" + + "5. get_labels - Get labels assigned to the issue.\n" + methods := []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"} + + issueNumberDescription := "The number of the issue" + required := []string{"method", "owner", "repo", "issue_number"} + + if withEvents { + methodDescription += "6. get_events - Get the issue's event history: labeled, assigned, closed, renamed, referenced and so on.\n" + + "7. get_timeline - Get the issue's timeline. A superset of get_events that also includes comments, commits and reviews, so prefer it when you need the full narrative and get_events when you only need state changes.\n" + + "8. get_event - Get a single issue event by its `event_id`. Takes `event_id` instead of `issue_number`.\n" + methods = append(methods, "get_events", "get_timeline", "get_event") + + issueNumberDescription = "The number of the issue. Required for every method except get_event." + // get_event is addressed by event_id and has no issue number to supply, so + // issue_number drops out of the required list and is enforced per method below. + required = []string{"method", "owner", "repo"} + } + schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "method": { - Type: "string", - Description: "The read operation to perform on a single issue.\n" + - "Options are:\n" + - "1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries, and `closed_by_pull_requests` summarizes the pull requests configured to close the issue as `total_count` plus up to 5 `references`.\n" + - "2. get_comments - Get issue comments.\n" + - "3. get_sub_issues - Get sub-issues (children) of the issue.\n" + - "4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n" + - "5. get_labels - Get labels assigned to the issue.\n", - Enum: []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"}, + Type: "string", + Description: methodDescription, + Enum: methods, }, "owner": { Type: "string", @@ -814,10 +849,16 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { }, "issue_number": { Type: "number", - Description: "The number of the issue", + Description: issueNumberDescription, }, }, - Required: []string{"method", "owner", "repo", "issue_number"}, + Required: required, + } + if withEvents { + schema.Properties["event_id"] = &jsonschema.Schema{ + Type: "number", + Description: "The ID of the issue event. Required for, and only used by, the get_event method.", + } } WithPagination(schema) @@ -847,9 +888,19 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + // The event history methods exist only on the feature-gated variant, so reject + // them here in case a client calls one without honoring the schema. + if !withEvents && slices.Contains([]string{"get_events", "get_timeline", "get_event"}, method) { + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + + // get_event is addressed by event_id, so it is the one method with no issue number. + var issueNumber int + if method != "get_event" { + issueNumber, err = RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } } pagination, err := OptionalPaginationParams(args) @@ -888,6 +939,19 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { case "get_labels": result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) return attachIFC(result), nil, err + case "get_events": + result, err := GetIssueEvents(ctx, client, deps, owner, repo, issueNumber, pagination) + return attachIFC(result), nil, err + case "get_timeline": + result, err := GetIssueTimeline(ctx, client, deps, owner, repo, issueNumber, pagination) + return attachIFC(result), nil, err + case "get_event": + eventID, err := RequiredBigInt(args, "event_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetIssueEvent(ctx, client, deps, owner, repo, eventID) + return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -1062,6 +1126,179 @@ func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDepen return MarshalledTextResult(minimalComments), nil } +// eventActorLogin reports the login to attribute an event or timeline entry to. `commented` +// and `reviewed` entries name their author in User and leave Actor empty, so both fields are +// consulted before an entry is treated as unattributable. +func eventActorLogin(actor, user *github.User) string { + if login := actor.GetLogin(); login != "" { + return login + } + return user.GetLogin() +} + +// GetIssueEvents returns the event history of an issue: the state changes (labeled, assigned, +// closed, renamed, ...) without the comment bodies that get_timeline adds. +func GetIssueEvents(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + cache, err := deps.GetRepoAccessCache(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get repo access cache: %w", err) + } + flags := deps.GetFlags(ctx) + + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + events, resp, err := client.Issues.ListIssueEvents(ctx, owner, repo, issueNumber, opts) + if err != nil { + return nil, fmt.Errorf("failed to get issue events: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue events", resp, body), nil + } + + // Events carry user-authored content (renamed titles), so under lockdown they are + // filtered by actor exactly as issue comments are. + if flags.LockdownMode { + if cache == nil { + return nil, fmt.Errorf("lockdown cache is not configured") + } + filtered := make([]*github.IssueEvent, 0, len(events)) + for _, event := range events { + if event == nil { + continue + } + login := eventActorLogin(event.GetActor(), nil) + if login == "" { + continue + } + isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil + } + if isSafeContent { + filtered = append(filtered, event) + } + } + events = filtered + } + + minimalEvents := make([]MinimalIssueEvent, 0, len(events)) + for _, event := range events { + if event == nil { + continue + } + minimalEvents = append(minimalEvents, convertToMinimalIssueEvent(event)) + } + + return MarshalledTextResult(minimalEvents), nil +} + +// GetIssueTimeline returns the timeline of an issue: a superset of GetIssueEvents that also +// includes comments, commits and reviews. +func GetIssueTimeline(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + cache, err := deps.GetRepoAccessCache(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get repo access cache: %w", err) + } + flags := deps.GetFlags(ctx) + + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + items, resp, err := client.Issues.ListIssueTimeline(ctx, owner, repo, issueNumber, opts) + if err != nil { + return nil, fmt.Errorf("failed to get issue timeline: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue timeline", resp, body), nil + } + + // The timeline embeds comment and review bodies, so an entry whose author lacks push + // access is dropped entirely under lockdown. + if flags.LockdownMode { + if cache == nil { + return nil, fmt.Errorf("lockdown cache is not configured") + } + filtered := make([]*github.Timeline, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + login := eventActorLogin(item.GetActor(), item.GetUser()) + if login == "" { + continue + } + isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil + } + if isSafeContent { + filtered = append(filtered, item) + } + } + items = filtered + } + + minimalItems := make([]MinimalTimelineItem, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + minimalItems = append(minimalItems, convertToMinimalTimelineItem(item)) + } + + return MarshalledTextResult(minimalItems), nil +} + +// GetIssueEvent returns a single issue event addressed by its own id. With only one event to +// return, lockdown mode rejects the read outright instead of filtering, as `get` does. +func GetIssueEvent(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, eventID int64) (*mcp.CallToolResult, error) { + cache, err := deps.GetRepoAccessCache(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get repo access cache: %w", err) + } + flags := deps.GetFlags(ctx) + + event, resp, err := client.Issues.GetEvent(ctx, owner, repo, eventID) + if err != nil { + return nil, fmt.Errorf("failed to get issue event: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue event", resp, body), nil + } + + if flags.LockdownMode { + login := eventActorLogin(event.GetActor(), nil) + if restricted, err := authorLockdownResult(ctx, cache, owner, repo, login, lockdownIssueEventRestrictedMessage); restricted != nil || err != nil { + return restricted, err + } + } + + return MarshalledTextResult(convertToMinimalIssueEvent(event)), nil +} + func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { diff --git a/pkg/github/issues_events_test.go b/pkg/github/issues_events_test.go new file mode 100644 index 0000000000..37f3523a1a --- /dev/null +++ b/pkg/github/issues_events_test.go @@ -0,0 +1,787 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// issueEventsDeps builds the dependencies for an issue_read call, optionally with lockdown mode +// enabled and backed by a permission server where "maintainer" has push access and every other +// login does not. +func issueEventsDeps(t *testing.T, mockedClient *http.Client, lockdownEnabled bool) BaseDeps { + t.Helper() + + var restClient *github.Client + if lockdownEnabled { + restClient = mockRESTPermissionServer(t, "read", map[string]string{ + "maintainer": "write", + "outsider": "read", + }) + } + + return BaseDeps{ + Client: mustNewGHClient(t, mockedClient), + GQLClient: defaultGQLClient, + RepoAccessCache: stubRepoAccessCache(restClient, 15*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": lockdownEnabled}), + } +} + +func Test_IssueReadWithEvents_ToolDefinition(t *testing.T) { + t.Parallel() + + serverTool := IssueReadWithEvents(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueEvents, tool)) + + assert.Equal(t, "issue_read", tool.Name) + assert.Equal(t, FeatureFlagIssueEvents, serverTool.FeatureFlagEnable) + assert.True(t, tool.Annotations.ReadOnlyHint) + + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "event_id") + assert.Subset(t, schema.Properties["method"].Enum, []any{"get_events", "get_timeline", "get_event"}) + + // issue_number leaves the required list on this variant so get_event, which is addressed by + // event_id, does not have to invent one. + assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo"}) +} + +// Test_IssueRead_WithoutEventsFlag asserts the ungated tool is untouched by this feature: it +// neither advertises nor serves the event history methods. +func Test_IssueRead_WithoutEventsFlag(t *testing.T) { + t.Parallel() + + serverTool := IssueRead(translations.NullTranslationHelper) + schema := serverTool.Tool.InputSchema.(*jsonschema.Schema) + + assert.Equal(t, []string{FeatureFlagIssueEvents}, serverTool.FeatureFlagDisable) + assert.Empty(t, serverTool.FeatureFlagEnable) + assert.NotContains(t, schema.Properties, "event_id") + assert.NotContains(t, schema.Properties["method"].Enum, "get_events") + assert.NotContains(t, schema.Properties["method"].Enum, "get_timeline") + assert.NotContains(t, schema.Properties["method"].Enum, "get_event") + assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "issue_number"}) + + for _, method := range []string{"get_events", "get_timeline", "get_event"} { + t.Run(method, func(t *testing.T) { + deps := issueEventsDeps(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), false) + request := createMCPRequest(map[string]any{ + "method": method, + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "event_id": float64(1), + }) + + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.Contains(t, getErrorResult(t, result).Text, "unknown method: "+method) + }) + } +} + +func Test_GetIssueEvents(t *testing.T) { + serverTool := IssueReadWithEvents(translations.NullTranslationHelper) + + mockEvents := []*github.IssueEvent{ + { + ID: github.Ptr(int64(123)), + Event: github.Ptr("labeled"), + Actor: &github.User{Login: github.Ptr("maintainer")}, + Label: &github.Label{Name: "bug"}, + CreatedAt: &github.Timestamp{Time: time.Date(2025, 5, 22, 10, 0, 0, 0, time.UTC)}, + }, + { + ID: github.Ptr(int64(456)), + Event: github.Ptr("renamed"), + Actor: &github.User{Login: github.Ptr("outsider")}, + Rename: &github.Rename{From: github.Ptr("old title"), To: github.Ptr("new title")}, + CommitID: github.Ptr("abc123"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + lockdownEnabled bool + expectError bool + expectedErrMsg string + expectedEvents []MinimalIssueEvent + }{ + { + name: "successful events retrieval", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockEvents), + }), + requestArgs: map[string]any{ + "method": "get_events", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectedEvents: []MinimalIssueEvent{ + { + ID: 123, + Event: "labeled", + Label: "bug", + CreatedAt: "2025-05-22T10:00:00Z", + }, + { + ID: 456, + Event: "renamed", + CommitID: "abc123", + RenamedFrom: "old title", + RenamedTo: "new title", + }, + }, + }, + { + name: "successful events retrieval with pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventsByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockEvents), + ), + }), + requestArgs: map[string]any{ + "method": "get_events", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectedEvents: []MinimalIssueEvent{{ID: 123}, {ID: 456}}, + }, + { + name: "issue not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), + requestArgs: map[string]any{ + "method": "get_events", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue events", + }, + { + name: "missing issue_number", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockEvents), + }), + requestArgs: map[string]any{ + "method": "get_events", + "owner": "owner", + "repo": "repo", + }, + expectedErrMsg: "missing required parameter: issue_number", + }, + { + name: "lockdown filters events from actors without push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockEvents), + }), + requestArgs: map[string]any{ + "method": "get_events", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + lockdownEnabled: true, + expectedEvents: []MinimalIssueEvent{ + {ID: 123, Event: "labeled", Label: "bug", CreatedAt: "2025-05-22T10:00:00Z"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := issueEventsDeps(t, tc.mockedClient, tc.lockdownEnabled) + request := createMCPRequest(tc.requestArgs) + + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NoError(t, err) + require.NotNil(t, result) + + if tc.expectedEvents == nil { + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + return + } + + var returned []MinimalIssueEvent + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, len(tc.expectedEvents)) + for i, expected := range tc.expectedEvents { + assert.Equal(t, expected.ID, returned[i].ID) + if expected.Event != "" { + assert.Equal(t, expected.Event, returned[i].Event) + assert.Equal(t, expected.Label, returned[i].Label) + assert.Equal(t, expected.CommitID, returned[i].CommitID) + assert.Equal(t, expected.RenamedFrom, returned[i].RenamedFrom) + assert.Equal(t, expected.RenamedTo, returned[i].RenamedTo) + assert.Equal(t, expected.CreatedAt, returned[i].CreatedAt) + } + } + }) + } +} + +func Test_GetIssueTimeline(t *testing.T) { + serverTool := IssueReadWithEvents(translations.NullTranslationHelper) + + mockTimeline := []*github.Timeline{ + { + ID: github.Ptr(int64(123)), + Event: github.Ptr("commented"), + User: &github.User{Login: github.Ptr("maintainer")}, + Body: github.Ptr("A comment on the issue"), + CreatedAt: &github.Timestamp{Time: time.Date(2025, 5, 22, 10, 0, 0, 0, time.UTC)}, + }, + { + ID: github.Ptr(int64(456)), + Event: github.Ptr("cross-referenced"), + Actor: &github.User{Login: github.Ptr("maintainer")}, + Source: &github.Source{ + Type: github.Ptr("issue"), + Actor: &github.User{Login: github.Ptr("maintainer")}, + Issue: &github.Issue{ + Number: github.Ptr(7), + Title: github.Ptr("Referring issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/7"), + }, + }, + }, + { + ID: github.Ptr(int64(789)), + Event: github.Ptr("committed"), + Actor: &github.User{Login: github.Ptr("outsider")}, + SHA: github.Ptr("def456"), + Message: github.Ptr("Fix the thing"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + lockdownEnabled bool + expectError bool + expectedErrMsg string + expectedIDs []int64 + }{ + { + name: "successful timeline retrieval", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesTimelineByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockTimeline), + }), + requestArgs: map[string]any{ + "method": "get_timeline", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectedIDs: []int64{123, 456, 789}, + }, + { + name: "successful timeline retrieval with pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesTimelineByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{ + "page": "3", + "per_page": "25", + }).andThen( + mockResponse(t, http.StatusOK, mockTimeline), + ), + }), + requestArgs: map[string]any{ + "method": "get_timeline", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(3), + "perPage": float64(25), + }, + expectedIDs: []int64{123, 456, 789}, + }, + { + name: "issue not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesTimelineByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), + requestArgs: map[string]any{ + "method": "get_timeline", + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue timeline", + }, + { + // The `commented` entry is attributed via User rather than Actor, so this also + // covers the User fallback in eventActorLogin. + name: "lockdown filters timeline entries from users without push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesTimelineByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockTimeline), + }), + requestArgs: map[string]any{ + "method": "get_timeline", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + lockdownEnabled: true, + expectedIDs: []int64{123, 456}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := issueEventsDeps(t, tc.mockedClient, tc.lockdownEnabled) + request := createMCPRequest(tc.requestArgs) + + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NoError(t, err) + + var returned []MinimalTimelineItem + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + + ids := make([]int64, 0, len(returned)) + for _, item := range returned { + ids = append(ids, item.ID) + } + assert.Equal(t, tc.expectedIDs, ids) + }) + } +} + +// Test_GetIssueTimeline_TrimsAndSanitizes pins the minimal shape: the comment body and the +// cross-referenced issue title survive, while the verbose upstream nesting does not. +func Test_GetIssueTimeline_TrimsAndSanitizes(t *testing.T) { + t.Parallel() + + serverTool := IssueReadWithEvents(translations.NullTranslationHelper) + deps := issueEventsDeps(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesTimelineByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Timeline{ + { + ID: github.Ptr(int64(1)), + Event: github.Ptr("cross-referenced"), + Actor: &github.User{Login: github.Ptr("maintainer")}, + Source: &github.Source{ + Type: github.Ptr("issue"), + Issue: &github.Issue{ + Number: github.Ptr(7), + Title: github.Ptr("Referring issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/other/repo/issues/7"), + Repository: &github.Repository{FullName: github.Ptr("other/repo")}, + }, + }, + }, + }), + }), false) + + request := createMCPRequest(map[string]any{ + "method": "get_timeline", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }) + + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + var returned []MinimalTimelineItem + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + + require.NotNil(t, returned[0].Source) + require.NotNil(t, returned[0].Source.Issue) + assert.Equal(t, "issue", returned[0].Source.Type) + assert.Equal(t, 7, returned[0].Source.Issue.Number) + assert.Equal(t, "Referring issue", returned[0].Source.Issue.Title) + assert.Equal(t, "other/repo", returned[0].Source.Issue.Repository) + + // The trimmed entry must not carry the upstream commit-ancestry payload. + var raw []map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &raw)) + assert.NotContains(t, raw[0], "parents") +} + +func Test_GetIssueEvent(t *testing.T) { + serverTool := IssueReadWithEvents(translations.NullTranslationHelper) + + // The single-event endpoint is the one that returns the issue the event belongs to, since + // the caller addresses the event by id and never supplies an issue number. + mockEvent := &github.IssueEvent{ + ID: github.Ptr(int64(17196710688)), + Event: github.Ptr("closed"), + Actor: &github.User{Login: github.Ptr("maintainer")}, + CommitID: github.Ptr("abc123"), + CreatedAt: &github.Timestamp{Time: time.Date(2025, 5, 22, 10, 0, 0, 0, time.UTC)}, + // No nested Repository: the REST payload carries repository_url instead, so the ref's + // Repository stays empty and the issue is understood to be in the addressed repo. + Issue: &github.Issue{ + Number: github.Ptr(42), + Title: github.Ptr("Something broke"), + State: github.Ptr("closed"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + lockdownEnabled bool + expectError bool + expectResultErr bool + expectedErrMsg string + expectedEventID int64 + expectedActor string + expectedCommitID string + expectedIssueNum int + }{ + { + name: "successful single event retrieval", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventByOwnerByRepoByEventID: mockResponse(t, http.StatusOK, mockEvent), + }), + requestArgs: map[string]any{ + "method": "get_event", + "owner": "owner", + "repo": "repo", + "event_id": float64(17196710688), + }, + expectedEventID: 17196710688, + expectedActor: "maintainer", + expectedCommitID: "abc123", + expectedIssueNum: 42, + }, + { + // get_event takes no issue_number, which is the reason it is left out of the + // schema-level required list. + name: "succeeds without issue_number", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventByOwnerByRepoByEventID: mockResponse(t, http.StatusOK, mockEvent), + }), + requestArgs: map[string]any{ + "method": "get_event", + "owner": "owner", + "repo": "repo", + "event_id": float64(17196710688), + }, + expectedEventID: 17196710688, + expectedActor: "maintainer", + expectedCommitID: "abc123", + expectedIssueNum: 42, + }, + { + name: "missing event_id", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventByOwnerByRepoByEventID: mockResponse(t, http.StatusOK, mockEvent), + }), + requestArgs: map[string]any{ + "method": "get_event", + "owner": "owner", + "repo": "repo", + }, + expectResultErr: true, + expectedErrMsg: "missing required parameter: event_id", + }, + { + name: "event not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventByOwnerByRepoByEventID: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), + requestArgs: map[string]any{ + "method": "get_event", + "owner": "owner", + "repo": "repo", + "event_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue event", + }, + { + name: "lockdown allows event from actor with push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventByOwnerByRepoByEventID: mockResponse(t, http.StatusOK, mockEvent), + }), + requestArgs: map[string]any{ + "method": "get_event", + "owner": "owner", + "repo": "repo", + "event_id": float64(17196710688), + }, + lockdownEnabled: true, + expectedEventID: 17196710688, + expectedActor: "maintainer", + expectedCommitID: "abc123", + expectedIssueNum: 42, + }, + { + // A single event cannot be filtered down, so lockdown refuses the read outright. + name: "lockdown rejects event from actor without push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventByOwnerByRepoByEventID: mockResponse(t, http.StatusOK, &github.IssueEvent{ + ID: github.Ptr(int64(555)), + Event: github.Ptr("renamed"), + Actor: &github.User{Login: github.Ptr("outsider")}, + }), + }), + requestArgs: map[string]any{ + "method": "get_event", + "owner": "owner", + "repo": "repo", + "event_id": float64(555), + }, + lockdownEnabled: true, + expectResultErr: true, + expectedErrMsg: "access to issue event is restricted by lockdown mode", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := issueEventsDeps(t, tc.mockedClient, tc.lockdownEnabled) + request := createMCPRequest(tc.requestArgs) + + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NoError(t, err) + require.NotNil(t, result) + + if tc.expectResultErr { + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + return + } + + var returned MinimalIssueEvent + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, tc.expectedEventID, returned.ID) + require.NotNil(t, returned.Actor) + assert.Equal(t, tc.expectedActor, returned.Actor.Login) + assert.Equal(t, tc.expectedCommitID, returned.CommitID) + if tc.expectedIssueNum == 0 { + assert.Nil(t, returned.Issue) + } else { + require.NotNil(t, returned.Issue) + assert.Equal(t, tc.expectedIssueNum, returned.Issue.Number) + assert.Equal(t, "Something broke", returned.Issue.Title) + assert.Empty(t, returned.Issue.Repository) + } + }) + } +} + +func Test_repoFullNameFromCommitURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + expected string + }{ + { + name: "cross-repo commit on a referenced event", + url: "https://api.github.com/repos/saraycp/open-build-service/commits/5291b58b04", + expected: "saraycp/open-build-service", + }, + { + name: "fork branch on a force-push event", + url: "https://api.github.com/repos/artemsaveliev/github-mcp-server/commits/79b2a855ab", + expected: "artemsaveliev/github-mcp-server", + }, + { + name: "GHES host", + url: "https://github.example.com/api/v3/repos/owner/repo/commits/abc123", + expected: "owner/repo", + }, + {name: "empty url", url: "", expected: ""}, + {name: "no repos segment", url: "https://api.github.com/user", expected: ""}, + {name: "owner only", url: "https://api.github.com/repos/owner", expected: ""}, + {name: "trailing slash after owner", url: "https://api.github.com/repos/owner/", expected: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, repoFullNameFromCommitURL(tc.url)) + }) + } +} + +func Test_GetIssueTimeline_CarriesCommitRepository(t *testing.T) { + t.Parallel() + + // A bare commit_id is unresolvable when the commit lives outside this repo, which is the + // common case for referenced and force-push entries. + serverTool := IssueReadWithEvents(translations.NullTranslationHelper) + deps := issueEventsDeps(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesTimelineByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Timeline{ + { + Event: github.Ptr("referenced"), + Actor: &github.User{Login: github.Ptr("outsider")}, + CommitID: github.Ptr("5291b58b04"), + CommitURL: github.Ptr("https://api.github.com/repos/other/unrelated/commits/5291b58b04"), + }, + { + Event: github.Ptr("labeled"), + Actor: &github.User{Login: github.Ptr("maintainer")}, + Label: &github.Label{Name: "bug"}, + CommitID: github.Ptr(""), + }, + }), + }), false) + + request := createMCPRequest(map[string]any{ + "method": "get_timeline", + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }) + + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + var returned []MinimalTimelineItem + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 2) + + assert.Equal(t, "5291b58b04", returned[0].CommitID) + assert.Equal(t, "other/unrelated", returned[0].CommitRepository) + + // Entries with no commit stay clean rather than carrying an empty key. + assert.Empty(t, returned[1].CommitRepository) + var raw []map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &raw)) + assert.NotContains(t, raw[1], "commit_repository") +} + +func Test_GetIssueEvent_CarriesDismissedReview(t *testing.T) { + t.Parallel() + + serverTool := IssueReadWithEvents(translations.NullTranslationHelper) + deps := issueEventsDeps(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesEventByOwnerByRepoByEventID: mockResponse(t, http.StatusOK, &github.IssueEvent{ + ID: github.Ptr(int64(99)), + Event: github.Ptr("review_dismissed"), + Actor: &github.User{Login: github.Ptr("maintainer")}, + DismissedReview: &github.DismissedReview{ + State: github.Ptr("changes_requested"), + ReviewID: github.Ptr(int64(456)), + DismissalMessage: github.Ptr("Stale after rebase"), + DismissalCommitID: github.Ptr("def456"), + }, + }), + }), false) + + request := createMCPRequest(map[string]any{ + "method": "get_event", + "owner": "owner", + "repo": "repo", + "event_id": float64(99), + }) + + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + var returned MinimalIssueEvent + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + + require.NotNil(t, returned.DismissedReview) + assert.Equal(t, "changes_requested", returned.DismissedReview.State) + assert.Equal(t, int64(456), returned.DismissedReview.ReviewID) + assert.Equal(t, "def456", returned.DismissedReview.DismissalCommitID) + // The dismissal message is author-supplied, so it is sanitized like any other body text. + assert.NotContains(t, returned.DismissedReview.DismissalMessage, "