Skip to content

Commit ec5ed1f

Browse files
committed
Support multiple repositories via sub-addresses, fixes #2
1 parent 94f54eb commit ec5ed1f

File tree

5 files changed

+80
-29
lines changed

5 files changed

+80
-29
lines changed

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
# ticket-dispatcher
22

33
ticket-dispatcher posts comments on GitHub issues via email sent to a specific
4-
domain such as `NNN@issues.example.com` which will post to a GitHub repository
5-
for issue NNN.
4+
domain such as `NNN@issues.example.com` which will post to an issue in the
5+
GitHub repository `org/foo` defined via the `GITHUB_PROJECT` environment
6+
variable.
7+
8+
Emails sent to
9+
[sub-addresses](https://en.wikipedia.org/wiki/Email_address#Sub-addressing)
10+
such as `NNN+other-project@issues.example.com` where `other-project` is the
11+
sub-address or tag, are forwarded to org/other-project, with the organisation
12+
name `org` determined from `GITHUB_PROJECT`.
613

714
## Development
815

@@ -17,6 +24,12 @@ go build
1724
go test
1825
```
1926

27+
## Infrastructure
28+
29+
ticket-dispatcher is currently deployed on AWS infrastructure and makes use of
30+
AWS Lambda for the ticket-dispatcher function, AWS SES for email receiving and
31+
AWS S3 for email storage.
32+
2033
## Deployment
2134

2235
### Generate a GitHub PAT
@@ -128,4 +141,4 @@ Once deployed, the ticket-dispatcher lambda function can be updated by calling
128141
./scripts/update-lambda.sh
129142
```
130143

131-
**Logs**: Cloudwatch logs can be found at `/aws/lambda/ticket-dispatcher`
144+
**Logs**: Cloudwatch logs can be found at `/aws/lambda/ticket-dispatcher`

extract_metadata.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import (
66
"unicode"
77
)
88

9-
// extractIssueNumber scans To and Cc headers and returns the first numeric local-part found.
10-
func extractIssueNumber(toHeader, ccHeader string) string {
9+
// extractIssueNumber scans To and Cc headers and returns the first numeric local-part found,
10+
// along with an optional repo suffix extracted from a + tag (e.g. 123+myrepo@domain → "123", "myrepo").
11+
func extractIssueNumber(toHeader, ccHeader string) (issueNumber, repoSuffix string) {
1112
// Combine headers; ParseAddressList handles comma-separated lists
1213
headers := []string{toHeader, ccHeader}
1314

@@ -24,8 +25,9 @@ func extractIssueNumber(toHeader, ccHeader string) string {
2425
for _, p := range parts {
2526
if strings.Contains(p, "@") {
2627
stringParts := strings.SplitN(p, "@", 2)
27-
if isDigits(stringParts[0]) && stringParts[1] == ticketDomain {
28-
return stringParts[0]
28+
num, repo := splitLocalPart(stringParts[0])
29+
if isDigits(num) && stringParts[1] == ticketDomain {
30+
return num, repo
2931
}
3032
}
3133
}
@@ -42,12 +44,22 @@ func extractIssueNumber(toHeader, ccHeader string) string {
4244
}
4345
local := parts[0]
4446
domain := parts[1]
45-
if isDigits(local) && domain == ticketDomain {
46-
return local
47+
num, repo := splitLocalPart(local)
48+
if isDigits(num) && domain == ticketDomain {
49+
return num, repo
4750
}
4851
}
4952
}
50-
return ""
53+
return "", ""
54+
}
55+
56+
// splitLocalPart splits an email local part on the first '+'.
57+
// "123+myrepo" → ("123", "myrepo"); "123" → ("123", "").
58+
func splitLocalPart(local string) (num, repo string) {
59+
if i := strings.IndexByte(local, '+'); i >= 0 {
60+
return local[:i], local[i+1:]
61+
}
62+
return local, ""
5163
}
5264

5365
// extractSenderDomain parses the From header and returns the domain (lowercased) or empty string.

extract_metadata_test.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,40 @@ func setupTests(t *testing.T) {
1111
func TestExtractIssueNumber(t *testing.T) {
1212
setupTests(t)
1313
tests := []struct {
14-
to string
15-
want string
16-
}{{
17-
to: "John Doe <johndoe@example.com>",
18-
want: "",
19-
},
20-
{to: "John Doe <johndoe@example.com>, 123@issues.example.com",
21-
want: "123",
14+
to string
15+
wantIssue string
16+
wantRepo string
17+
}{
18+
{
19+
to: "John Doe <johndoe@example.com>",
20+
wantIssue: "",
21+
wantRepo: "",
22+
},
23+
{
24+
to: "John Doe <johndoe@example.com>, 123@issues.example.com",
25+
wantIssue: "123",
26+
wantRepo: "",
27+
},
28+
{
29+
to: "123+myrepo@issues.example.com",
30+
wantIssue: "123",
31+
wantRepo: "myrepo",
32+
},
33+
{
34+
to: "John Doe <johndoe@example.com>, 456+other-repo@issues.example.com",
35+
wantIssue: "456",
36+
wantRepo: "other-repo",
2237
},
2338
}
2439

2540
for _, tc := range tests {
2641
t.Run(tc.to, func(t *testing.T) {
27-
got := extractIssueNumber(tc.to, "")
28-
if got != tc.want {
29-
t.Errorf("extractIssueNumber mismatch:\n--- got ---\n%q\n--- want ---\n%q\n", got, tc.want)
42+
gotIssue, gotRepo := extractIssueNumber(tc.to, "")
43+
if gotIssue != tc.wantIssue {
44+
t.Errorf("extractIssueNumber issue mismatch:\n--- got ---\n%q\n--- want ---\n%q\n", gotIssue, tc.wantIssue)
45+
}
46+
if gotRepo != tc.wantRepo {
47+
t.Errorf("extractIssueNumber repo mismatch:\n--- got ---\n%q\n--- want ---\n%q\n", gotRepo, tc.wantRepo)
3048
}
3149
})
3250
}

issue_comments.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ type ghComment struct {
1616
Body string `json:"body"`
1717
}
1818

19-
func postIssueComment(issueNumber, msgId, comment string) error {
20-
exists, err := commentWithMessageIDExists(issueNumber, msgId)
19+
func postIssueComment(project, issueNumber, msgId, comment string) error {
20+
exists, err := commentWithMessageIDExists(project, issueNumber, msgId)
2121
// only suppress posting if we get confirmation that Message-ID was found
2222
// better to post twice than silently fail
2323
if exists {
@@ -33,7 +33,7 @@ func postIssueComment(issueNumber, msgId, comment string) error {
3333

3434
url := fmt.Sprintf(
3535
"https://api.github.com/repos/%s/issues/%s/comments",
36-
githubProject, issueNumber,
36+
project, issueNumber,
3737
)
3838
payload := map[string]string{
3939
"body": fmt.Sprintf("Message-ID: %s\n", msgId) + comment,
@@ -72,7 +72,7 @@ func postIssueComment(issueNumber, msgId, comment string) error {
7272

7373
// commentWithMessageIDExists checks whether an issue already has a comment
7474
// whose first line contains the given Message-ID (exact match or contains).
75-
func commentWithMessageIDExists(issueNumber, messageID string) (bool, error) {
75+
func commentWithMessageIDExists(project, issueNumber, messageID string) (bool, error) {
7676
token := os.Getenv("GITHUB_TOKEN")
7777

7878
if token == "" {
@@ -86,7 +86,7 @@ func commentWithMessageIDExists(issueNumber, messageID string) (bool, error) {
8686
for {
8787
url := fmt.Sprintf(
8888
"https://api.github.com/repos/%s/issues/%s/comments?per_page=100&page=%d",
89-
githubProject, issueNumber, page,
89+
project, issueNumber, page,
9090
)
9191

9292
req, err := http.NewRequest(http.MethodGet, url, nil)

main.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func handler(ctx context.Context, s3Event events.S3Event) error {
8282
subject := msg.Header.Get("Subject")
8383
auth := msg.Header.Get("Authentication-Results")
8484

85-
issue := extractIssueNumber(toHeader, ccHeader)
85+
issue, repoSuffix := extractIssueNumber(toHeader, ccHeader)
8686
senderDomain := extractSenderDomain(fromHeader)
8787

8888
if !strings.Contains(auth, "spf=pass") && !strings.Contains(auth, "dkim=pass") {
@@ -94,13 +94,21 @@ func handler(ctx context.Context, s3Event events.S3Event) error {
9494
if issue == "" {
9595
log.Fatalf("no issue number found in To: or Cc:")
9696
}
97-
log.Printf("%s | From: %s; To: %s; Subject: %s\n", msgId, fromHeader, toHeader, subject)
97+
effectiveProject := githubProject
98+
if repoSuffix != "" && githubProject != "" {
99+
org := githubProject
100+
if i := strings.IndexByte(githubProject, '/'); i >= 0 {
101+
org = githubProject[:i]
102+
}
103+
effectiveProject = org + "/" + repoSuffix
104+
}
105+
log.Printf("%s | From: %s; To: %s; Subject: %s; Project: %s\n", msgId, fromHeader, toHeader, subject, effectiveProject)
98106
body, err := extractBodyAsMarkdown(msg)
99107
if err != nil {
100108
log.Fatalf("error in extracting message body")
101109
} else {
102110
header := fmt.Sprintf("From: %s\n\n", fromHeader)
103-
err := postIssueComment(issue, msgId, header+hideQuotedPart(body, removeQuotes))
111+
err := postIssueComment(effectiveProject, issue, msgId, header+hideQuotedPart(body, removeQuotes))
104112
if err != nil {
105113
log.Printf("postIssueComment err=%v", err)
106114
}

0 commit comments

Comments
 (0)