Commit adf5aff3a611
Changed files (5)
tools
review-tool
internal
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