You record a bug. You watch it back. You open Jira, create a ticket, paste the video link, copy the transcript, and fill in the title. That's four context switches for something that should take one click.
SendRec now has built-in Jira and GitHub integrations. From any video, click "Create Issue" and a ticket appears in your project tracker with the video link and a transcript excerpt already attached.
Here's how we built it.
The IssueCreator interface
We wanted to support multiple providers without the handler knowing which one it's talking to. A simple interface does the job:
type IssueCreator interface {
CreateIssue(ctx context.Context, req CreateIssueRequest) (*CreateIssueResponse, error)
ValidateConfig(ctx context.Context) error
}
type CreateIssueRequest struct {
Title string
Description string
VideoURL string
}
CreateIssue files the ticket. ValidateConfig checks credentials before you save — so you find out immediately if your token is wrong, not when you try to file your first bug.
Adding a new provider means implementing two methods. The handler, the settings UI, and the config storage don't change.
GitHub: Bearer token and markdown
GitHub Issues have a straightforward API. POST to /repos/{owner}/{repo}/issues with a Bearer token:
func (c *GitHubClient) CreateIssue(ctx context.Context, req CreateIssueRequest) (*CreateIssueResponse, error) {
body := map[string]any{
"title": req.Title,
"body": formatGitHubBody(req),
}
bodyJSON, _ := json.Marshal(body)
url := fmt.Sprintf("%s/repos/%s/%s/issues", c.baseURL, c.owner, c.repo)
httpReq, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyJSON))
httpReq.Header.Set("Authorization", "Bearer "+c.token)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(httpReq)
// ...
}
The issue body is markdown with the video link prominent and the transcript in a collapsible <details> block so it doesn't overwhelm the ticket:
func formatGitHubBody(req CreateIssueRequest) string {
body := fmt.Sprintf("**Video:** %s\n\n", req.VideoURL)
if req.Description != "" {
body += "<details>\n<summary>Transcript</summary>\n\n"
body += req.Description
body += "\n\n</details>"
}
return body
}
ValidateConfig hits two endpoints: GET /user to verify the token works, then GET /repos/{owner}/{repo} to confirm the repo exists and the token has access.
Jira: Basic auth and Atlassian Document Format
Jira Cloud uses Basic auth (email + API token, base64-encoded) and requires the Atlassian Document Format (ADF) instead of plain text or markdown. ADF is a JSON tree that describes rich content:
func buildADFDescription(req CreateIssueRequest) map[string]any {
content := []any{adfParagraph(adfInlineCard(req.VideoURL))}
if req.Description != "" {
content = append(content,
adfParagraph(adfText("Transcript:")),
adfCodeBlock(req.Description),
)
}
return map[string]any{"version": 1, "type": "doc", "content": content}
}
The video URL renders as an inline card in Jira — Atlassian automatically fetches the page metadata and shows a rich preview. The transcript goes in a code block to preserve formatting.
ValidateConfig calls GET /rest/api/3/myself — the simplest authenticated endpoint Jira offers. If it returns 200, your credentials work.
Encrypting API tokens at rest
Integration configs live in a user_integrations table as JSONB. Storing API tokens in plaintext would be reckless, even in a self-hosted database. We encrypt token fields with AES-256-GCM before writing them:
func Encrypt(key []byte, plaintext string) (string, error) {
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
io.ReadFull(rand.Reader, nonce)
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
The encryption key is derived from the existing JWT_SECRET environment variable via SHA-256 — no new secrets to configure. Each encryption uses a random nonce, so the same token produces different ciphertexts each time.
When listing integrations, we decrypt the token, then mask it (show first 4 characters, replace the rest with asterisks). The full token never leaves the server after initial save.
One subtlety: when a user edits their config (say, changing the repo name) without re-entering the token, the frontend sends the masked value back. The backend detects this, loads the existing encrypted token from the database, and preserves it. No accidental token loss.
The settings UI
Each provider gets a collapsible card in Settings. Connected providers show a green "Connected" badge. Expand the card to see the config fields, pre-filled from saved data (with tokens masked).
Three buttons per provider:
- Save — validates all required fields, encrypts tokens, upserts the config
-
Test Connection — calls
ValidateConfigagainst the live API - Disconnect — deletes the config entirely
Creating issues from videos
On the VideoDetail page, a "Create Issue" button appears if you have at least one integration configured. If you have both GitHub and Jira connected, it becomes a dropdown so you can pick which tracker to file in.
Clicking it sends the video's title, share URL, and a transcript excerpt (first 500 characters) to the provider. The response includes the issue URL, which opens in a new tab.
What we learned
Keep the provider interface minimal. Two methods — CreateIssue and ValidateConfig — are enough. We were tempted to add ListProjects, GetIssueTypes, and other discovery methods, but that would couple the UI to each provider's data model. A flat config with required fields (token, owner, repo for GitHub; base URL, email, API token, project key for Jira) is simpler and works.
Encrypt at the boundary. Token encryption happens in the handler, not in the provider clients. The GitHub and Jira clients receive plaintext tokens and don't know encryption exists. This keeps the providers testable with simple string arguments.
Test against real API shapes. Both providers have test suites using httptest.Server that return realistic response payloads. This caught a bug early — Jira's create-issue response uses key (like PROJ-123), not id, for the issue identifier.
Try it
The integrations are live at app.sendrec.eu. Go to Settings, expand GitHub or Jira, enter your credentials, and hit Test Connection. Then open any video and click Create Issue.
Self-hosting? Pull the latest image and the migration runs automatically. No new environment variables needed — encryption uses your existing JWT_SECRET.
Source code: github.com/sendrec/sendrec
Top comments (0)