Commit adf5aff3a611

Vincent Demeester <vincent@sbr.pm>
2026-01-27 13:12:01
feat(review-tool): add GitHub discussions and comments support
Use GraphQL API to fetch: - Discussions created by user - Discussion comments by user - Issue/PR comments by user Also adds proper type names in markdown output for new activity types. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4bd6a12
Changed files (5)
tools
tools/review-tool/internal/config/config.go
@@ -27,13 +27,15 @@ type UserConfig struct {
 
 // GitHubConfig configures the GitHub source.
 type GitHubConfig struct {
-	Enabled        bool     `yaml:"enabled"`
-	Username       string   `yaml:"username"`
-	Repos          []string `yaml:"repos,omitempty"`
-	IncludePRs     bool     `yaml:"include_prs"`
-	IncludeIssues  bool     `yaml:"include_issues"`
-	IncludeReviews bool     `yaml:"include_reviews"`
-	IncludeCommits bool     `yaml:"include_commits"`
+	Enabled            bool     `yaml:"enabled"`
+	Username           string   `yaml:"username"`
+	Repos              []string `yaml:"repos,omitempty"`
+	IncludePRs         bool     `yaml:"include_prs"`
+	IncludeIssues      bool     `yaml:"include_issues"`
+	IncludeReviews     bool     `yaml:"include_reviews"`
+	IncludeCommits     bool     `yaml:"include_commits"`
+	IncludeDiscussions bool     `yaml:"include_discussions"`
+	IncludeComments    bool     `yaml:"include_comments"`
 }
 
 // OrgConfig configures the org-mode source.
@@ -108,11 +110,13 @@ func DefaultConfig() *Config {
 	return &Config{
 		User: UserConfig{},
 		GitHub: GitHubConfig{
-			Enabled:        true,
-			IncludePRs:     true,
-			IncludeIssues:  true,
-			IncludeReviews: true,
-			IncludeCommits: false,
+			Enabled:            true,
+			IncludePRs:         true,
+			IncludeIssues:      true,
+			IncludeReviews:     true,
+			IncludeCommits:     false,
+			IncludeDiscussions: true,
+			IncludeComments:    true,
 		},
 		Org: OrgConfig{
 			Enabled: true,
tools/review-tool/internal/output/markdown.go
@@ -113,16 +113,20 @@ func formatSourceName(source string) string {
 
 func formatTypeName(typ string) string {
 	names := map[string]string{
-		"pr_merged":     "PRs Merged",
-		"pr_reviewed":   "PRs Reviewed",
-		"issue_created": "Issues Created",
-		"issue_closed":  "Issues Closed",
-		"todo_done":     "Tasks Completed",
-		"state_change":  "State Changes",
-		"clock_entry":   "Time Logged",
-		"session":       "Sessions",
-		"learning":      "Learnings",
-		"research":      "Research",
+		"pr_merged":          "PRs Merged",
+		"pr_reviewed":        "PRs Reviewed",
+		"issue_created":      "Issues Created",
+		"issue_closed":       "Issues Closed",
+		"issue_updated":      "Issues Updated",
+		"comment":            "Comments",
+		"discussion":         "Discussions Started",
+		"discussion_comment": "Discussion Comments",
+		"todo_done":          "Tasks Completed",
+		"state_change":       "State Changes",
+		"clock_entry":        "Time Logged",
+		"session":            "Sessions",
+		"learning":           "Learnings",
+		"research":           "Research",
 	}
 	if name, ok := names[typ]; ok {
 		return name
tools/review-tool/internal/sources/github.go
@@ -69,6 +69,22 @@ func (g *GitHubSource) Fetch(ctx context.Context, start, end time.Time) (*activi
 		}
 	}
 
+	// Fetch discussions
+	if g.cfg.IncludeDiscussions {
+		items, err := g.fetchDiscussions(ctx, start)
+		if err == nil {
+			act.Items = append(act.Items, filterByDateRange(items, start, end)...)
+		}
+	}
+
+	// Fetch comments
+	if g.cfg.IncludeComments {
+		items, err := g.fetchComments(ctx, start)
+		if err == nil {
+			act.Items = append(act.Items, filterByDateRange(items, start, end)...)
+		}
+	}
+
 	return act, nil
 }
 
@@ -216,6 +232,247 @@ func parseIssueJSON(data []byte) ([]activity.ActivityItem, error) {
 	return items, nil
 }
 
+func (g *GitHubSource) fetchDiscussions(ctx context.Context, since time.Time) ([]activity.ActivityItem, error) {
+	// Use GraphQL to search for discussions authored by the user
+	query := `{
+  viewer {
+    repositoryDiscussions(first: 50, orderBy: {field: CREATED_AT, direction: DESC}) {
+      nodes {
+        title
+        url
+        createdAt
+        category {
+          name
+        }
+        repository {
+          nameWithOwner
+        }
+      }
+    }
+    repositoryDiscussionComments(first: 50, orderBy: {field: UPDATED_AT, direction: DESC}) {
+      nodes {
+        discussion {
+          title
+          url
+          repository {
+            nameWithOwner
+          }
+        }
+        createdAt
+        url
+      }
+    }
+  }
+}`
+	args := []string{
+		"api", "graphql",
+		"-f", fmt.Sprintf("query=%s", query),
+	}
+
+	cmd := exec.CommandContext(ctx, "gh", args...)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("gh api graphql failed: %s", stderr.String())
+	}
+
+	return parseDiscussionGraphQL(stdout.Bytes(), since)
+}
+
+// graphqlDiscussionResponse represents the GraphQL response
+type graphqlDiscussionResponse struct {
+	Data struct {
+		Viewer struct {
+			RepositoryDiscussions struct {
+				Nodes []struct {
+					Title     string `json:"title"`
+					URL       string `json:"url"`
+					CreatedAt string `json:"createdAt"`
+					Category  struct {
+						Name string `json:"name"`
+					} `json:"category"`
+					Repository struct {
+						NameWithOwner string `json:"nameWithOwner"`
+					} `json:"repository"`
+				} `json:"nodes"`
+			} `json:"repositoryDiscussions"`
+			RepositoryDiscussionComments struct {
+				Nodes []struct {
+					Discussion struct {
+						Title      string `json:"title"`
+						URL        string `json:"url"`
+						Repository struct {
+							NameWithOwner string `json:"nameWithOwner"`
+						} `json:"repository"`
+					} `json:"discussion"`
+					CreatedAt string `json:"createdAt"`
+					URL       string `json:"url"`
+				} `json:"nodes"`
+			} `json:"repositoryDiscussionComments"`
+		} `json:"viewer"`
+	} `json:"data"`
+}
+
+func parseDiscussionGraphQL(data []byte, since time.Time) ([]activity.ActivityItem, error) {
+	var resp graphqlDiscussionResponse
+	if err := json.Unmarshal(data, &resp); err != nil {
+		return nil, err
+	}
+
+	var items []activity.ActivityItem
+	seen := make(map[string]bool)
+
+	// Add discussions authored by user
+	for _, d := range resp.Data.Viewer.RepositoryDiscussions.Nodes {
+		ts, _ := time.Parse(time.RFC3339, d.CreatedAt)
+		if ts.Before(since) {
+			continue
+		}
+		if seen[d.URL] {
+			continue
+		}
+		seen[d.URL] = true
+
+		items = append(items, activity.ActivityItem{
+			ID:        fmt.Sprintf("github:discussion:%s", d.URL),
+			Title:     d.Title,
+			Type:      "discussion",
+			Category:  activity.CategoryGitHub,
+			Timestamp: ts,
+			URL:       d.URL,
+			Metadata: map[string]string{
+				"repository": d.Repository.NameWithOwner,
+				"category":   d.Category.Name,
+			},
+		})
+	}
+
+	// Add discussions where user commented
+	for _, c := range resp.Data.Viewer.RepositoryDiscussionComments.Nodes {
+		ts, _ := time.Parse(time.RFC3339, c.CreatedAt)
+		if ts.Before(since) {
+			continue
+		}
+		if seen[c.URL] {
+			continue
+		}
+		seen[c.URL] = true
+
+		items = append(items, activity.ActivityItem{
+			ID:        fmt.Sprintf("github:discussion_comment:%s", c.URL),
+			Title:     fmt.Sprintf("Comment on discussion: %s", c.Discussion.Title),
+			Type:      "discussion_comment",
+			Category:  activity.CategoryGitHub,
+			Timestamp: ts,
+			URL:       c.URL,
+			Metadata: map[string]string{
+				"repository":       c.Discussion.Repository.NameWithOwner,
+				"discussion_title": c.Discussion.Title,
+			},
+		})
+	}
+
+	return items, nil
+}
+
+func (g *GitHubSource) fetchComments(ctx context.Context, since time.Time) ([]activity.ActivityItem, error) {
+	// Use GraphQL to get issue/PR comments by the user
+	query := `{
+  viewer {
+    issueComments(first: 50, orderBy: {field: UPDATED_AT, direction: DESC}) {
+      nodes {
+        body
+        url
+        createdAt
+        issue {
+          title
+          number
+          repository {
+            nameWithOwner
+          }
+        }
+      }
+    }
+  }
+}`
+	args := []string{
+		"api", "graphql",
+		"-f", fmt.Sprintf("query=%s", query),
+	}
+
+	cmd := exec.CommandContext(ctx, "gh", args...)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("gh api graphql (comments) failed: %s", stderr.String())
+	}
+
+	return parseCommentsGraphQL(stdout.Bytes(), since)
+}
+
+// graphqlCommentsResponse represents the GraphQL response for comments
+type graphqlCommentsResponse struct {
+	Data struct {
+		Viewer struct {
+			IssueComments struct {
+				Nodes []struct {
+					Body      string `json:"body"`
+					URL       string `json:"url"`
+					CreatedAt string `json:"createdAt"`
+					Issue     struct {
+						Title      string `json:"title"`
+						Number     int    `json:"number"`
+						Repository struct {
+							NameWithOwner string `json:"nameWithOwner"`
+						} `json:"repository"`
+					} `json:"issue"`
+				} `json:"nodes"`
+			} `json:"issueComments"`
+		} `json:"viewer"`
+	} `json:"data"`
+}
+
+func parseCommentsGraphQL(data []byte, since time.Time) ([]activity.ActivityItem, error) {
+	var resp graphqlCommentsResponse
+	if err := json.Unmarshal(data, &resp); err != nil {
+		return nil, err
+	}
+
+	var items []activity.ActivityItem
+	seen := make(map[string]bool)
+
+	for _, c := range resp.Data.Viewer.IssueComments.Nodes {
+		ts, _ := time.Parse(time.RFC3339, c.CreatedAt)
+		if ts.Before(since) {
+			continue
+		}
+		if seen[c.URL] {
+			continue
+		}
+		seen[c.URL] = true
+
+		items = append(items, activity.ActivityItem{
+			ID:        fmt.Sprintf("github:comment:%s", c.URL),
+			Title:     fmt.Sprintf("Comment on: %s", c.Issue.Title),
+			Type:      "comment",
+			Category:  activity.CategoryGitHub,
+			Timestamp: ts,
+			URL:       c.URL,
+			Metadata: map[string]string{
+				"repository":   c.Issue.Repository.NameWithOwner,
+				"issue_number": fmt.Sprintf("%d", c.Issue.Number),
+				"issue_title":  c.Issue.Title,
+			},
+		})
+	}
+
+	return items, nil
+}
+
 func filterByDateRange(items []activity.ActivityItem, start, end time.Time) []activity.ActivityItem {
 	filtered := make([]activity.ActivityItem, 0)
 	for _, item := range items {
@@ -225,3 +482,77 @@ func filterByDateRange(items []activity.ActivityItem, start, end time.Time) []ac
 	}
 	return filtered
 }
+
+// discussionResult represents a discussion from GraphQL API
+type discussionResult struct {
+	Title      string `json:"title"`
+	URL        string `json:"url"`
+	CreatedAt  string `json:"createdAt"`
+	Repository string `json:"repository"`
+	Category   string `json:"category"`
+}
+
+// commentResult represents a comment from GraphQL API
+type commentResult struct {
+	Body        string `json:"body"`
+	URL         string `json:"url"`
+	CreatedAt   string `json:"createdAt"`
+	Repository  string `json:"repository"`
+	IssueTitle  string `json:"issueTitle"`
+	IssueNumber int    `json:"issueNumber"`
+}
+
+func parseDiscussionJSON(data []byte) ([]activity.ActivityItem, error) {
+	var discussions []discussionResult
+	if err := json.Unmarshal(data, &discussions); err != nil {
+		return nil, err
+	}
+
+	items := make([]activity.ActivityItem, 0, len(discussions))
+	for _, d := range discussions {
+		ts, _ := time.Parse(time.RFC3339, d.CreatedAt)
+
+		items = append(items, activity.ActivityItem{
+			ID:        fmt.Sprintf("github:discussion:%s", d.URL),
+			Title:     d.Title,
+			Type:      "discussion",
+			Category:  activity.CategoryGitHub,
+			Timestamp: ts,
+			URL:       d.URL,
+			Metadata: map[string]string{
+				"repository": d.Repository,
+				"category":   d.Category,
+			},
+		})
+	}
+
+	return items, nil
+}
+
+func parseCommentJSON(data []byte) ([]activity.ActivityItem, error) {
+	var comments []commentResult
+	if err := json.Unmarshal(data, &comments); err != nil {
+		return nil, err
+	}
+
+	items := make([]activity.ActivityItem, 0, len(comments))
+	for _, c := range comments {
+		ts, _ := time.Parse(time.RFC3339, c.CreatedAt)
+
+		items = append(items, activity.ActivityItem{
+			ID:        fmt.Sprintf("github:comment:%s", c.URL),
+			Title:     fmt.Sprintf("Comment on: %s", c.IssueTitle),
+			Type:      "comment",
+			Category:  activity.CategoryGitHub,
+			Timestamp: ts,
+			URL:       c.URL,
+			Metadata: map[string]string{
+				"repository":   c.Repository,
+				"issue_number": fmt.Sprintf("%d", c.IssueNumber),
+				"issue_title":  c.IssueTitle,
+			},
+		})
+	}
+
+	return items, nil
+}
tools/review-tool/internal/sources/github_test.go
@@ -77,6 +77,74 @@ func TestGitHubSource_FilterByDateRange(t *testing.T) {
 	}
 }
 
+func TestGitHubSource_ParseDiscussionJSON(t *testing.T) {
+	jsonData := `[
+		{
+			"title": "How to configure CI/CD?",
+			"url": "https://github.com/tektoncd/pipeline/discussions/123",
+			"createdAt": "2026-01-25T14:30:00Z",
+			"repository": "tektoncd/pipeline",
+			"category": "Q&A"
+		}
+	]`
+
+	items, err := parseDiscussionJSON([]byte(jsonData))
+	if err != nil {
+		t.Fatalf("parseDiscussionJSON() error = %v", err)
+	}
+
+	if len(items) != 1 {
+		t.Fatalf("expected 1 item, got %d", len(items))
+	}
+
+	item := items[0]
+	if item.Title != "How to configure CI/CD?" {
+		t.Errorf("Title = %q, want %q", item.Title, "How to configure CI/CD?")
+	}
+	if item.Type != "discussion" {
+		t.Errorf("Type = %q, want %q", item.Type, "discussion")
+	}
+	if item.Category != activity.CategoryGitHub {
+		t.Errorf("Category = %q, want %q", item.Category, activity.CategoryGitHub)
+	}
+	if item.Metadata["category"] != "Q&A" {
+		t.Errorf("category = %q, want %q", item.Metadata["category"], "Q&A")
+	}
+}
+
+func TestGitHubSource_ParseCommentJSON(t *testing.T) {
+	jsonData := `[
+		{
+			"body": "Thanks for the fix!",
+			"url": "https://github.com/tektoncd/pipeline/issues/456#issuecomment-123",
+			"createdAt": "2026-01-26T10:15:00Z",
+			"repository": "tektoncd/pipeline",
+			"issueTitle": "Bug in pipeline controller",
+			"issueNumber": 456
+		}
+	]`
+
+	items, err := parseCommentJSON([]byte(jsonData))
+	if err != nil {
+		t.Fatalf("parseCommentJSON() error = %v", err)
+	}
+
+	if len(items) != 1 {
+		t.Fatalf("expected 1 item, got %d", len(items))
+	}
+
+	item := items[0]
+	if item.Title != "Comment on: Bug in pipeline controller" {
+		t.Errorf("Title = %q, want %q", item.Title, "Comment on: Bug in pipeline controller")
+	}
+	if item.Type != "comment" {
+		t.Errorf("Type = %q, want %q", item.Type, "comment")
+	}
+	if item.Metadata["issue_number"] != "456" {
+		t.Errorf("issue_number = %q, want %q", item.Metadata["issue_number"], "456")
+	}
+}
+
 // Integration test - requires gh CLI
 func TestGitHubSource_RealData(t *testing.T) {
 	// Check if gh CLI is available
@@ -110,3 +178,38 @@ func TestGitHubSource_RealData(t *testing.T) {
 		}
 	}
 }
+
+// Integration test for discussions and comments
+func TestGitHubSource_DiscussionsAndComments(t *testing.T) {
+	if _, err := exec.LookPath("gh"); err != nil {
+		t.Skip("gh CLI not found, skipping integration test")
+	}
+
+	cfg := &config.GitHubConfig{
+		Enabled:            true,
+		IncludePRs:         false,
+		IncludeIssues:      false,
+		IncludeReviews:     false,
+		IncludeCommits:     false,
+		IncludeDiscussions: true,
+		IncludeComments:    true,
+	}
+
+	source := NewGitHubSource(cfg)
+
+	// Last 7 days
+	now := time.Now()
+	start := time.Date(now.Year(), now.Month(), now.Day()-7, 0, 0, 0, 0, now.Location())
+
+	act, err := source.Fetch(t.Context(), start, now)
+	if err != nil {
+		t.Fatalf("Fetch error: %v", err)
+	}
+
+	t.Logf("Found %d discussion/comment items", len(act.Items))
+	for i, item := range act.Items {
+		if i < 10 {
+			t.Logf("  [%s] %s (%s)", item.Type, item.Title, item.Metadata["repository"])
+		}
+	}
+}
tools/review-tool/.gitignore
@@ -0,0 +1,4 @@
+# Binaries
+review-tool
+review-tool-debug
+*.exe