From b2e67101c486a466ccc83d11e9d2444a77d28ecd Mon Sep 17 00:00:00 2001 From: Steph Dietz Date: Thu, 30 Jan 2025 15:21:04 -0600 Subject: [PATCH] add new github summarizor example --- modus-gh-issue-summarizer/.gitignore | 16 + modus-gh-issue-summarizer/README.md | 108 +++++ modus-gh-issue-summarizer/go.mod | 14 + modus-gh-issue-summarizer/go.sum | 12 + modus-gh-issue-summarizer/main.go | 392 ++++++++++++++++++ modus-gh-issue-summarizer/modus.json | 23 + .../workflows/issue-summarizer.yml | 23 + 7 files changed, 588 insertions(+) create mode 100644 modus-gh-issue-summarizer/.gitignore create mode 100644 modus-gh-issue-summarizer/README.md create mode 100644 modus-gh-issue-summarizer/go.mod create mode 100644 modus-gh-issue-summarizer/go.sum create mode 100644 modus-gh-issue-summarizer/main.go create mode 100644 modus-gh-issue-summarizer/modus.json create mode 100644 modus-gh-issue-summarizer/workflows/issue-summarizer.yml diff --git a/modus-gh-issue-summarizer/.gitignore b/modus-gh-issue-summarizer/.gitignore new file mode 100644 index 0000000..b19d2da --- /dev/null +++ b/modus-gh-issue-summarizer/.gitignore @@ -0,0 +1,16 @@ +# Ignore macOS system files +.DS_Store + +# Ignore environment variable files +.env +.env.* + +# Ignore build output directories +build/ + +# Ignore Go debuger and generated files +__debug_bin* +*_generated.go +*.generated.go + +.modusdb/ diff --git a/modus-gh-issue-summarizer/README.md b/modus-gh-issue-summarizer/README.md new file mode 100644 index 0000000..fdb0424 --- /dev/null +++ b/modus-gh-issue-summarizer/README.md @@ -0,0 +1,108 @@ +# GitHub Issue Knowledge Base Generator (Modus + Hypermode) 🚀 + +This project automatically generates a Knowledge Base (KB) article when a GitHub issue is closed. It utilizes Hypermode and Modus (a serverless Go framework) to process issue data and generate a structured KB article using an LLM. The article is then posted as a GitHub Discussion. + +## 🛠 Features + +- Fetches GitHub issue details and comments upon issue closure. +- Uses LLM (Hugging Face model via Hypermode) to generate a detailed KB article. +- Posts the generated KB article as a GitHub Discussion. +- Can be triggered manually or via GitHub Actions. + +-- + +## 🖥️ Running Locally + +### 1️⃣ Install Modus + +First, install Modus CLI: + +``` +npm install -g @hypermodeinc/modus +``` + +### 2️⃣ Clone the Repository + +``` +git clone https://github.com/YOUR_USERNAME/YOUR_REPO.git +cd YOUR_REPO +``` + +### 3️⃣ Start the Modus Dev Server + +``` +modus dev +``` + +### 4️⃣ Test the API Locally + +Go to: + +``` +http://localhost:54321/graphql +``` + +You'll see an exported function `issueClosedHandler`. Test the KB Article Generation. Enter the repository name and issue number and run the query. The API will return a formatted KB article. You can also post the KB Article as a Discussion. To do so, pass a GitHub API token as well. + +-- + +## 🚀 Deploying to Hypermode + +To deploy the function to Hypermode: + +``` +modus deploy +``` + +Once deployed, copy your Hypermode endpoint, which will be used in the GitHub Action. + +-- + +## 🔧 Setting Up the GitHub Action + +This action allows the KB article to be generated automatically when an issue is closed. + +### 1️⃣ Clone the Project & Deploy to Hypermode + +If you haven't already: + +``` +git clone https://github.com/YOUR_USERNAME/YOUR_REPO.git +cd YOUR_REPO +modus deploy +``` + +Copy your Hypermode endpoint. + +### 2️⃣ Add the GitHub Action + +Copy the issue-summarizer.yml file into in your repository: + +### 3️⃣ Add Your Hypermode API Key to GitHub Secrets + +1. Go to your GitHub repository. +2. Navigate to Settings → Secrets and variables → Actions. +3. Click New repository secret. +4. Name it: `HYPERMODE_API_KEY` +5. Paste your Hypermode API key. +6. Save. + +### 4️⃣ Done! 🎉 + +Now, every time a GitHub issue is closed, a KB article will be generated and posted as a GitHub Discussion. + +-- + +## 🛠 Troubleshooting + +GitHub Discussion Not Created? + +Ensure: + +- You have enabled Discussions under Repository Settings → Features. +- Your GitHub token has the discussions: write permission. + +No Response from the API? + +- Check if your Modus function is deployed (modus deploy). +- Verify that your Hypermode API key is correct in GitHub Secrets. diff --git a/modus-gh-issue-summarizer/go.mod b/modus-gh-issue-summarizer/go.mod new file mode 100644 index 0000000..ec04c14 --- /dev/null +++ b/modus-gh-issue-summarizer/go.mod @@ -0,0 +1,14 @@ +module modus-gh-issue-summarizer + +go 1.23.3 + +toolchain go1.23.5 + +require github.com/hypermodeinc/modus/sdk/go v0.17.0 + +require ( + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect +) diff --git a/modus-gh-issue-summarizer/go.sum b/modus-gh-issue-summarizer/go.sum new file mode 100644 index 0000000..fbe3044 --- /dev/null +++ b/modus-gh-issue-summarizer/go.sum @@ -0,0 +1,12 @@ +github.com/hypermodeinc/modus/sdk/go v0.17.0 h1:vyS82iw31xuxqQKxiVtwrSNuF6hkMEJoynlcxthAeYM= +github.com/hypermodeinc/modus/sdk/go v0.17.0/go.mod h1:UzVpTQDjloJuErOiNP3Tma3N8crE5qwYfKQyX1ecKlA= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= diff --git a/modus-gh-issue-summarizer/main.go b/modus-gh-issue-summarizer/main.go new file mode 100644 index 0000000..923c414 --- /dev/null +++ b/modus-gh-issue-summarizer/main.go @@ -0,0 +1,392 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "github.com/hypermodeinc/modus/sdk/go/pkg/http" + "github.com/hypermodeinc/modus/sdk/go/pkg/models" + "github.com/hypermodeinc/modus/sdk/go/pkg/models/openai" +) + +type GitHubUser struct { + Login string `json:"login"` + HTMLURL string `json:"html_url"` + Type string `json:"type"` + SiteAdmin bool `json:"site_admin"` +} + +type GitHubLabel struct { + Name string `json:"name"` +} + +type GitHubReactions struct { + URL string `json:"url"` + TotalCount int `json:"total_count"` + PlusOne int `json:"+1"` + MinusOne int `json:"-1"` + Laugh int `json:"laugh"` + Hooray int `json:"hooray"` + Confused int `json:"confused"` + Heart int `json:"heart"` + Rocket int `json:"rocket"` + Eyes int `json:"eyes"` +} + +type GitHubIssue struct { + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Number int `json:"number"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ClosedAt string `json:"closed_at"` + User GitHubUser `json:"user"` + Labels []GitHubLabel `json:"labels"` + Comments int `json:"comments"` + CommentsURL string `json:"comments_url"` + Reactions GitHubReactions `json:"reactions"` +} + +type GitHubComment struct { + User GitHubUser `json:"user"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func postDiscussionToRepo(repo string, title string, body string, token string) error { + // Extract owner and repository name + parts := strings.Split(repo, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repository format: %s", repo) + } + owner, repoName := parts[0], parts[1] + + // Get the repository ID + repoID, err := getRepositoryID(owner, repoName, token) + if err != nil { + return fmt.Errorf("error fetching repository ID: %w", err) + } + + // Get the first available discussion category + categoryID, err := getDiscussionCategoryID(repoID, token) + if err != nil { + return fmt.Errorf("error fetching discussion category ID: %w", err) + } + + // GraphQL mutation to create a discussion + createDiscussionMutation := ` + mutation($repoID: ID!, $categoryID: ID!, $title: String!, $body: String!) { + createDiscussion(input: {repositoryId: $repoID, categoryId: $categoryID, title: $title, body: $body}) { + discussion { + url + } + } + }` + + // Variables for the mutation + variables := map[string]string{ + "repoID": repoID, + "categoryID": categoryID, + "title": title, + "body": body, + } + + payload := map[string]interface{}{ + "query": createDiscussionMutation, + "variables": variables, + } + + // Send the request + options := &http.RequestOptions{ + Method: "POST", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + }, + Body: payload, + } + + request := http.NewRequest("https://api.github.com/graphql", options) + response, err := http.Fetch(request) + if err != nil { + return fmt.Errorf("error creating discussion: %w", err) + } + + if response.Status != 200 { + return fmt.Errorf("failed to create discussion, status: %d, body: %s", response.Status, string(response.Body)) + } + + fmt.Println("✅ Discussion created successfully.") + return nil +} + +// Fetch repository ID from GitHub GraphQL API +func getRepositoryID(owner string, repoName string, token string) (string, error) { + query := ` + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + } + }` + + variables := map[string]string{ + "owner": owner, + "name": repoName, + } + + payload := map[string]interface{}{ + "query": query, + "variables": variables, + } + + options := &http.RequestOptions{ + Method: "POST", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + }, + Body: payload, + } + + request := http.NewRequest("https://api.github.com/graphql", options) + response, err := http.Fetch(request) + if err != nil { + return "", fmt.Errorf("error fetching repository ID: %w", err) + } + + var result struct { + Data struct { + Repository struct { + ID string `json:"id"` + } `json:"repository"` + } `json:"data"` + } + + if err := json.Unmarshal(response.Body, &result); err != nil { + return "", fmt.Errorf("error parsing repository ID response: %w", err) + } + + return result.Data.Repository.ID, nil +} + +// Fetch the first available discussion category ID +func getDiscussionCategoryID(repoID string, token string) (string, error) { + query := ` + query($repoID: ID!) { + node(id: $repoID) { + ... on Repository { + discussionCategories(first: 1) { + nodes { + id + } + } + } + } + }` + + variables := map[string]string{ + "repoID": repoID, + } + + payload := map[string]interface{}{ + "query": query, + "variables": variables, + } + + options := &http.RequestOptions{ + Method: "POST", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + }, + Body: payload, + } + + request := http.NewRequest("https://api.github.com/graphql", options) + response, err := http.Fetch(request) + if err != nil { + return "", fmt.Errorf("error fetching discussion category ID: %w", err) + } + + var result struct { + Data struct { + Node struct { + DiscussionCategories struct { + Nodes []struct { + ID string `json:"id"` + } `json:"nodes"` + } `json:"discussionCategories"` + } `json:"node"` + } `json:"data"` + } + + if err := json.Unmarshal(response.Body, &result); err != nil { + return "", fmt.Errorf("error parsing discussion category ID response: %w", err) + } + + // Return the first available category + if len(result.Data.Node.DiscussionCategories.Nodes) > 0 { + return result.Data.Node.DiscussionCategories.Nodes[0].ID, nil + } + + return "", fmt.Errorf("no discussion categories found in repository") +} + + +func generateKBArticle(issue *GitHubIssue, comments []GitHubComment) (string, error) { + prompt := fmt.Sprintf(` + Generate a detailed markdown article givin a concise summary of the following GitHub issue. Include the problem, the solution (if on exists), and any other relevant details. Please mention the users involved and any significant involvement in the issue. + + ### Issue Details: + - **Title**: %s + - **Description**: %s + - **State**: %s + - **Created by**: %s + - **Created at**: %s + - **Labels**: %v + - **Reactions**: 👍 (%d), 👎 (%d), ❤️ (%d), 🎉 (%d), 🚀 (%d), 👀 (%d) + + ### Comments: + %s + + Generate the output in markdown format. + `, + issue.Title, issue.Body, issue.State, issue.User.Login, issue.CreatedAt, + getLabelNames(issue.Labels), + issue.Reactions.PlusOne, issue.Reactions.MinusOne, issue.Reactions.Heart, + issue.Reactions.Hooray, issue.Reactions.Rocket, issue.Reactions.Eyes, + formatComments(comments), + ) + + model, err := models.GetModel[openai.ChatModel]("generate-article") + if err != nil { + return "", fmt.Errorf("error fetching model: %w", err) + } + + input, err := model.CreateInput( + openai.NewSystemMessage("You are an assistant that generates markdown documentation."), + openai.NewUserMessage(prompt), + ) + if err != nil { + return "", fmt.Errorf("error creating model input: %w", err) + } + + input.Temperature = 0.7 + + output, err := model.Invoke(input) + if err != nil { + return "", fmt.Errorf("error invoking model: %w", err) + } + + return strings.TrimSpace(output.Choices[0].Message.Content), nil +} + +func getLabelNames(labels []GitHubLabel) string { + names := []string{} + for _, label := range labels { + names = append(names, label.Name) + } + return strings.Join(names, ", ") +} + +func formatComments(comments []GitHubComment) string { + formatted := []string{} + for _, comment := range comments { + formatted = append(formatted, fmt.Sprintf("- **%s** (%s): %s", comment.User.Login, comment.CreatedAt, comment.Body)) + } + return strings.Join(formatted, "\n") +} + +func fetchIssueDetails(repo string, issueNumber int) (*GitHubIssue, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/issues/%d", repo, issueNumber) + + options := &http.RequestOptions{ + Method: "GET", + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + + request := http.NewRequest(url, options) + response, err := http.Fetch(request) + if err != nil { + return nil, fmt.Errorf("error fetching issue details: %w", err) + } + + if response.Status != 200 { + fmt.Printf("Unexpected response status: %d\n", response.Status) + fmt.Printf("Response body: %s\n", string(response.Body)) + return nil, fmt.Errorf("unexpected status code: %d", response.Status) + } + + var issue GitHubIssue + if err := json.Unmarshal(response.Body, &issue); err != nil { + return nil, fmt.Errorf("error parsing issue details: %w", err) + } + + return &issue, nil +} + +func fetchIssueComments(commentsURL string) ([]GitHubComment, error) { + options := &http.RequestOptions{ + Method: "GET", + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + + request := http.NewRequest(commentsURL, options) + response, err := http.Fetch(request) + if err != nil { + return nil, fmt.Errorf("error fetching issue comments: %w", err) + } + + if response.Status != 200 { + fmt.Printf("Unexpected response status: %d\n", response.Status) + fmt.Printf("Response body: %s\n", string(response.Body)) + return nil, fmt.Errorf("unexpected status code: %d", response.Status) + } + + var comments []GitHubComment + if err := json.Unmarshal(response.Body, &comments); err != nil { + return nil, fmt.Errorf("error parsing comments: %w", err) + } + + return comments, nil +} + +func IssueClosedHandler(repo string, issueNumber int, token string) { + issue, err := fetchIssueDetails(repo, issueNumber) + if err != nil { + fmt.Printf("Error fetching issue details: %v\n", err) + return + } + + comments, err := fetchIssueComments(issue.CommentsURL) + if err != nil { + fmt.Printf("Error fetching issue comments: %v\n", err) + return + } + + kbArticle, err := generateKBArticle(issue, comments) + if err != nil { + fmt.Printf("Error generating KB article: %v\n", err) + return + } + + // Output the KB article + fmt.Printf(kbArticle) + + + // Post the KB article as a GitHub discussion + err = postDiscussionToRepo(repo, fmt.Sprintf("Issue Summary: %s", issue.Title), kbArticle, token) + if err != nil { + fmt.Printf("❌ Error posting KB article as a discussion: %v\n", err) + return + } + + fmt.Println("KB article successfully posted as a comment.") +} diff --git a/modus-gh-issue-summarizer/modus.json b/modus-gh-issue-summarizer/modus.json new file mode 100644 index 0000000..6cf9d2f --- /dev/null +++ b/modus-gh-issue-summarizer/modus.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://schema.hypermode.com/modus.json", + "endpoints": { + "default": { + "type": "graphql", + "path": "/graphql", + "auth": "bearer-token" + } + }, + "models": { + "generate-article": { + "sourceModel": "meta-llama/Meta-Llama-3.1-8B-Instruct", + "provider": "hugging-face", + "connection": "hypermode" + } + }, + "connections": { + "github": { + "type": "http", + "baseUrl": "https://api.github.com/" + } + } +} diff --git a/modus-gh-issue-summarizer/workflows/issue-summarizer.yml b/modus-gh-issue-summarizer/workflows/issue-summarizer.yml new file mode 100644 index 0000000..8277d29 --- /dev/null +++ b/modus-gh-issue-summarizer/workflows/issue-summarizer.yml @@ -0,0 +1,23 @@ +name: Issue Summarizer + +on: + issues: + types: + - closed + +jobs: + trigger-hypermode: + runs-on: ubuntu-latest + + permissions: + discussions: write + + steps: + - name: Trigger Hypermode Function + run: | + curl -X POST https://YOUR_HYPERMODE_ENDPOINT.hypermode.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.HYPERMODE_API_KEY }}" \ + -d '{ + "query": "query { issueClosedHandler(repo: \"${{ github.repository }}\", issueNumber: ${{ github.event.issue.number }}, token: \"${{ secrets.GITHUB_TOKEN }}\") }" + }'