Skip to content

Commit 190a865

Browse files
jazannemmrj
andauthored
allow users to automatically create flag links (#97)
Co-authored-by: Molly <molly.jones@launchdarkly.com>
1 parent 0e5653d commit 190a865

File tree

9 files changed

+247
-14
lines changed

9 files changed

+247
-14
lines changed

.github/workflows/main.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ jobs:
1717
with:
1818
project-key: demo-dan-042021-2
1919
environment-key: development
20-
access-token: ${{ secrets.LD_ACCESS_TOKEN }}
20+
access-token: ${{ secrets.LD_ACCESS_TOKEN_WRITER }}
2121
repo-token: ${{ secrets.GITHUB_TOKEN }}
2222
base-uri: https://app.launchdarkly.com
2323
max-flags: 200
24+
create-flag-links: true
2425
- name: Find flags summary
2526
run: |
2627
echo "flags addded or modified ${{ steps.find-flags.outputs.modified-flags-count }}"

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ Adds a comment to a pull request (PR) whenever a feature flag reference is found
77

88
## Permissions
99

10-
This action requires a [LaunchDarkly access token](https://docs.launchdarkly.com/home/account-security/api-access-tokens) with read access for the designated `project-key`. Access tokens should be stored as an [encrypted secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets).
10+
This action requires a [LaunchDarkly access token](https://docs.launchdarkly.com/home/account-security/api-access-tokens) with:
11+
12+
* Read access for the designated `project-key`
13+
* (Optional) `createFlagLink` action, if you have set the `create-flag-links` input to `true`
14+
15+
Access tokens should be stored as an [encrypted secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets).
1116

1217
To add a comment to a PR, the `repo-token` used requires `write` permission for PRs. You can also specify permissions for the workflow with:
1318

@@ -86,7 +91,22 @@ You can find more information on aliases at [launchdarkly/ld-find-code-refs](htt
8691

8792
This action does not support monorepos or searching for flags across LaunchDarkly projects.
8893

89-
<!-- action-docs-inputs -->
94+
<!-- action-docs-inputs action="action.yml" -->
95+
### Inputs
96+
97+
| name | description | required | default |
98+
| --- | --- | --- | --- |
99+
| `repo-token` | <p>Token to use to authorize comments on PR. Typically the <code>GITHUB_TOKEN</code> secret or equivalent <code>github.token</code>.</p> | `true` | `""` |
100+
| `access-token` | <p>LaunchDarkly access token</p> | `true` | `""` |
101+
| `project-key` | <p>LaunchDarkly project key</p> | `false` | `default` |
102+
| `environment-key` | <p>LaunchDarkly environment key for creating flag links</p> | `false` | `production` |
103+
| `placeholder-comment` | <p>Comment on PR when no flags are found. If flags are found in later commits, this comment will be updated.</p> | `false` | `false` |
104+
| `include-archived-flags` | <p>Scan for archived flags</p> | `false` | `true` |
105+
| `max-flags` | <p>Maximum number of flags to find per PR</p> | `false` | `5` |
106+
| `base-uri` | <p>The base URI for the LaunchDarkly server. Most members should use the default value.</p> | `false` | `https://app.launchdarkly.com` |
107+
| `check-extinctions` | <p>Check if removed flags still exist in codebase</p> | `false` | `true` |
108+
| `create-flag-links` | <p>Create links to flags in LaunchDarkly. To use this feature you must use an access token with the <code>createFlagLink</code> role.</p> | `false` | `false` |
109+
<!-- action-docs-inputs action="action.yml" -->
90110
### Inputs
91111

92112
| parameter | description | required | default |
@@ -102,7 +122,7 @@ This action does not support monorepos or searching for flags across LaunchDarkl
102122
| check-extinctions | Check if removed flags still exist in codebase | `false` | true |
103123
<!-- action-docs-inputs -->
104124

105-
<!-- action-docs-outputs -->
125+
<!-- action-docs-outputs action="action.yml"-->
106126
### Outputs
107127

108128
| parameter | description |

action.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,17 @@ inputs:
3838
required: false
3939
default: '5'
4040
base-uri:
41-
description: The base URI for the LaunchDarkly server. Most users should use the default value.
41+
description: The base URI for the LaunchDarkly server. Most members should use the default value.
4242
required: false
4343
default: 'https://app.launchdarkly.com'
4444
check-extinctions:
4545
description: Check if removed flags still exist in codebase
4646
required: false
4747
default: 'true'
48+
create-flag-links:
49+
description: Create links to flags in LaunchDarkly. To use this feature you must use an access token with the `createFlagLink` role.
50+
required: false
51+
default: 'false'
4852

4953
outputs:
5054
any-modified:

comments/comments.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,7 @@ func ProcessFlags(flagsRef refs.ReferenceSummary, flags []ldapi.FeatureFlag, con
137137
for _, flagKey := range flagsRef.RemovedKeys() {
138138
flagAliases := flagsRef.FlagsRemoved[flagKey]
139139
idx, _ := find(flags, flagKey)
140-
extinct := false
141-
if flagsRef.ExtinctFlags != nil {
142-
_, e := flagsRef.ExtinctFlags[flagKey]
143-
extinct = e
144-
}
140+
extinct := flagsRef.IsExtinct(flagKey)
145141
removedComment, err := githubFlagComment(flags[idx], flagAliases, false, extinct, config)
146142
if err != nil {
147143
gha.LogError(err)

config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Config struct {
2626
PlaceholderComment bool
2727
IncludeArchivedFlags bool
2828
CheckExtinctions bool
29+
CreateFlagLinks bool
2930
}
3031

3132
func ValidateInputandParse(ctx context.Context) (*Config, error) {
@@ -91,6 +92,11 @@ func ValidateInputandParse(ctx context.Context) (*Config, error) {
9192
config.CheckExtinctions = checkExtinctions
9293
}
9394

95+
if createFlagLinks, err := strconv.ParseBool(os.Getenv("INPUT_CREATE-FLAG-LINKS")); err == nil {
96+
// ignore error - default is false
97+
config.CreateFlagLinks = createFlagLinks
98+
}
99+
94100
config.GHClient = getGithubClient(ctx)
95101
return &config, nil
96102
}

internal/ldclient/flag_links.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package ldapi
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strconv"
10+
"strings"
11+
12+
"github.com/google/go-github/github"
13+
ldapi "github.com/launchdarkly/api-client-go/v13"
14+
lcr "github.com/launchdarkly/find-code-references-in-pull-request/config"
15+
gha "github.com/launchdarkly/find-code-references-in-pull-request/internal/github_actions"
16+
"github.com/launchdarkly/find-code-references-in-pull-request/internal/version"
17+
18+
flags "github.com/launchdarkly/find-code-references-in-pull-request/internal/references"
19+
)
20+
21+
func CreateFlagLinks(config *lcr.Config, flagsRef flags.ReferenceSummary, event *github.PullRequestEvent) {
22+
pr := event.PullRequest
23+
if pr == nil || pr.HTMLURL == nil || pr.ID == nil {
24+
gha.Debug("No pull request found in event")
25+
return
26+
}
27+
28+
numAdded := len(flagsRef.FlagsAdded)
29+
numRemoved := len(flagsRef.FlagsRemoved)
30+
31+
for key, aliases := range flagsRef.FlagsAdded {
32+
message := buildLinkMessage(key, aliases, "added", numAdded, numRemoved)
33+
link := makeFlagLinkRep(event, key, message)
34+
postFlagLink(config, *link, key)
35+
}
36+
37+
for key, aliases := range flagsRef.FlagsRemoved {
38+
action := "removed"
39+
if flagsRef.IsExtinct(key) {
40+
action = "extinct"
41+
}
42+
message := buildLinkMessage(key, aliases, action, numAdded, numRemoved)
43+
link := makeFlagLinkRep(event, key, message)
44+
postFlagLink(config, *link, key)
45+
}
46+
}
47+
48+
func postFlagLink(config *lcr.Config, link ldapi.FlagLinkPost, flagKey string) {
49+
requestBody, err := json.Marshal(link)
50+
if err != nil {
51+
gha.SetWarning("Failed to create flag link for %s", flagKey)
52+
gha.Debug("Unable to construct flag link payload")
53+
return
54+
}
55+
56+
url := fmt.Sprintf("%s/api/v2/flag-links/projects/%s/flags/%s", config.LdInstance, config.LdProject, flagKey)
57+
58+
gha.Debug("[POST %s]\n\n%s", url, string(requestBody))
59+
60+
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(requestBody))
61+
if err != nil {
62+
gha.SetWarning("Could not to create flag link request")
63+
return
64+
}
65+
req.Header.Set("Content-Type", "application/json")
66+
req.Header.Set("LD-API-Version", "beta")
67+
req.Header.Set("Authorization", config.ApiToken)
68+
req.Header.Add("User-Agent", fmt.Sprintf("find-code-references-pr/%s", version.Version))
69+
70+
client := new(http.Client)
71+
resp, err := client.Do(req)
72+
if err != nil {
73+
gha.SetWarning("Failed to create flag link for %s", flagKey)
74+
gha.Debug("Error when sending flag link request:\n\n%s", err.Error())
75+
return
76+
}
77+
78+
defer resp.Body.Close()
79+
body, err := io.ReadAll(resp.Body)
80+
if err != nil {
81+
gha.Debug("Could not parse flag link request")
82+
}
83+
84+
switch resp.StatusCode {
85+
case http.StatusCreated:
86+
gha.Log("[POST %s] Flag link created [url=%s]", url, *link.DeepLink)
87+
case http.StatusConflict:
88+
gha.Log("[POST %s] Flag link already exists [url=%s]", url, *link.DeepLink)
89+
default:
90+
gha.SetWarning("Failed to create flag link for %s", flagKey)
91+
gha.Log("[POST %s] Flag link request failed [status=%d]", url, resp.StatusCode)
92+
}
93+
94+
gha.Debug("Response:\n\n%s", string(body))
95+
}
96+
97+
func makeFlagLinkRep(event *github.PullRequestEvent, flagKey, message string) *ldapi.FlagLinkPost {
98+
pr := event.PullRequest
99+
if pr == nil || pr.HTMLURL == nil || pr.ID == nil {
100+
return nil
101+
}
102+
103+
metadata := map[string]string{
104+
"message": message,
105+
"prNumber": strconv.Itoa(*pr.Number),
106+
"prTitle": *pr.Title,
107+
"prBody": *pr.Body,
108+
"state": *pr.State,
109+
"avatarUrl": *pr.User.AvatarURL,
110+
"repoName": *event.Repo.FullName,
111+
"repoUrl": *event.Repo.HTMLURL,
112+
}
113+
114+
if pr.User.Name != nil {
115+
metadata["authorName"] = *pr.User.Name
116+
metadata["authorDisplayName"] = *pr.User.Name
117+
} else {
118+
metadata["authorDisplayName"] = *pr.User.Login
119+
metadata["authorLogin"] = *pr.User.Login
120+
}
121+
122+
var timestamp *int64
123+
if pr.CreatedAt != nil {
124+
m := pr.CreatedAt.UnixMilli()
125+
timestamp = &m
126+
}
127+
128+
// TODO enable integration once capability is available
129+
// integration := "github"
130+
id := strconv.FormatInt(*pr.ID, 10)
131+
// key must be unique
132+
key := fmt.Sprintf("github-pr-%s-%s", id, flagKey)
133+
134+
return &ldapi.FlagLinkPost{
135+
DeepLink: pr.HTMLURL,
136+
Key: &key,
137+
// IntegrationKey: &integration,
138+
Timestamp: timestamp,
139+
Title: getLinkTitle(event),
140+
Description: pr.Body,
141+
Metadata: &metadata,
142+
}
143+
}
144+
145+
func getLinkTitle(event *github.PullRequestEvent) *string {
146+
builder := new(strings.Builder)
147+
builder.WriteString(fmt.Sprintf("[%s]", *event.Repo.FullName))
148+
149+
pr := event.PullRequest
150+
if pr.Title != nil {
151+
builder.WriteString(" ")
152+
builder.WriteString(*pr.Title)
153+
if pr.Number != nil {
154+
builder.WriteString(fmt.Sprintf(" (#%d)", *pr.Number))
155+
}
156+
} else if pr.Number != nil {
157+
builder.WriteString(fmt.Sprintf(" PR #%d", *pr.Number))
158+
} else {
159+
builder.WriteString(" pull request")
160+
}
161+
162+
title := builder.String()
163+
164+
return &title
165+
}
166+
167+
func buildLinkMessage(key string, aliases []string, action string, added, removed int) string {
168+
builder := new(strings.Builder)
169+
builder.WriteString(fmt.Sprintf("Flag %s", action))
170+
if len(aliases) > 0 {
171+
builder.WriteString(fmt.Sprintf(" (aliases: %s)", strings.Join(aliases, ", ")))
172+
}
173+
174+
if added > 0 {
175+
count := added
176+
if action == "added" {
177+
count--
178+
}
179+
if count > 0 {
180+
builder.WriteString(fmt.Sprintf("\n\t- Added %d other flags", count))
181+
}
182+
}
183+
184+
if removed > 0 {
185+
count := removed
186+
if action == "added" {
187+
count--
188+
}
189+
if count > 0 {
190+
builder.WriteString(fmt.Sprintf("\n\t- Removed %d other flags", count))
191+
}
192+
}
193+
194+
return builder.String()
195+
}

internal/references/types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ func (fr ReferenceSummary) ExtinctKeys() []string {
3737
return keys
3838
}
3939

40+
func (fr ReferenceSummary) IsExtinct(key string) bool {
41+
_, ok := fr.ExtinctFlags[key]
42+
return ok
43+
}
44+
4045
func (fr ReferenceSummary) sortedKeys(keys map[string][]string) []string {
4146
sortedKeys := make([]string, 0, len(keys))
4247
for k := range keys {

main.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ func main() {
8181
gha.Log("Summarizing results")
8282
flagsRef := builder.Build()
8383

84+
// Set outputs
85+
setOutputs(config, flagsRef)
86+
8487
// Add comment
8588
gha.StartLogGroup("Processing comment...")
8689
existingComment := checkExistingComments(event, config, ctx)
@@ -95,8 +98,13 @@ func main() {
9598
}
9699
gha.EndLogGroup()
97100

98-
// Set outputs
99-
setOutputs(config, flagsRef)
101+
// Add flag links
102+
if config.CreateFlagLinks && postedComments != "" {
103+
// if postedComments is empty, we probably already created the flag links
104+
gha.StartLogGroup("Adding flag links...")
105+
ldclient.CreateFlagLinks(config, flagsRef, event)
106+
gha.EndLogGroup()
107+
}
100108

101109
failExit(err)
102110
}

testdata/test

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,3 @@ show-widgets
55
oldPricingBanner
66
show_widgets
77
oldPricingBanner
8-
mobile-app-promo-ios
9-
mobile-app-promo-ios

0 commit comments

Comments
 (0)