Skip to content

Commit 2e1e001

Browse files
authored
Example code to turn Firebase App Distribution in-app feedback into Jira issues. (#1028)
1 parent e967d4f commit 2e1e001

File tree

7 files changed

+385
-0
lines changed

7 files changed

+385
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
root: true,
3+
env: {
4+
es2017: true,
5+
node: true,
6+
},
7+
extends: [
8+
"eslint:recommended",
9+
"google",
10+
],
11+
rules: {
12+
quotes: ["error", "double"],
13+
},
14+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
firebase-debug.log*
8+
firebase-debug.*.log*
9+
10+
# Firebase cache
11+
.firebase/
12+
13+
# Firebase config
14+
15+
# Uncomment this if you'd like others to create their own Firebase project.
16+
# For a team working on the same Firebase project(s), it is recommended to leave
17+
# it commented so all members can deploy to the same project(s) in .firebaserc.
18+
# .firebaserc
19+
20+
# Runtime data
21+
pids
22+
*.pid
23+
*.seed
24+
*.pid.lock
25+
26+
# Directory for instrumented libs generated by jscoverage/JSCover
27+
lib-cov
28+
29+
# Coverage directory used by tools like istanbul
30+
coverage
31+
32+
# nyc test coverage
33+
.nyc_output
34+
35+
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
36+
.grunt
37+
38+
# Bower dependency directory (https://bower.io/)
39+
bower_components
40+
41+
# node-waf configuration
42+
.lock-wscript
43+
44+
# Compiled binary addons (http://nodejs.org/api/addons.html)
45+
build/Release
46+
47+
# Dependency directories
48+
node_modules/
49+
50+
# Optional npm cache directory
51+
.npm
52+
53+
# Optional eslint cache
54+
.eslintcache
55+
56+
# Optional REPL history
57+
.node_repl_history
58+
59+
# Output of 'npm pack'
60+
*.tgz
61+
62+
# Yarn Integrity file
63+
.yarn-integrity
64+
65+
# dotenv environment variables file
66+
.env
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"functions": [
3+
{
4+
"source": "functions",
5+
"codebase": "default",
6+
"ignore": [
7+
"node_modules",
8+
".git",
9+
"firebase-debug.log",
10+
"firebase-debug.*.log"
11+
],
12+
"predeploy": [
13+
"npm --prefix \"$RESOURCE_DIR\" run lint"
14+
]
15+
}
16+
]
17+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
module.exports = {
2+
root: true,
3+
env: {
4+
es6: true,
5+
node: true,
6+
},
7+
extends: [
8+
"eslint:recommended",
9+
"google",
10+
],
11+
rules: {
12+
quotes: ["error", "double"],
13+
},
14+
"parserOptions": {
15+
"sourceType": "module",
16+
"ecmaVersion": 2017
17+
}
18+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import {
2+
onInAppFeedbackPublished} from "firebase-functions/v2/alerts/appDistribution";
3+
import fetch from "node-fetch";
4+
import {FormData} from "formdata-polyfill/esm.min.js";
5+
import logger from "firebase-functions/logger";
6+
7+
const JIRA_URI = "https://mysite.atlassian.net";
8+
const PROJECT_KEY = "XY";
9+
const ISSUE_TYPE_ID = "10001";
10+
const ISSUE_LABELS = ["in-app"];
11+
const API_KEY_OWNER = "user@e,mail";
12+
const API_KEY = "am9zaHVhIHBhc3N3b3JkMTIz";
13+
const AUTH_HEADER = "Basic " +
14+
Buffer.from(`${API_KEY_OWNER}:${API_KEY}`).toString("base64");
15+
16+
export const handleInAppFeedback = async (event) => {
17+
const issueUri = await createIssue(event);
18+
if (event.data.payload.screenshotUri) {
19+
await uploadScreenshot(issueUri, event.data.payload.screenshotUri);
20+
}
21+
return true;
22+
};
23+
24+
export const feedbacktojira = onInAppFeedbackPublished(handleInAppFeedback);
25+
26+
/**
27+
* Creates new issue in Jira.
28+
* @param {AlertEvent<InAppFeedbackPayload>} event
29+
*/
30+
async function createIssue(event) {
31+
const requestJson = await buildCreateIssueRequest(event);
32+
const requestBody = JSON.stringify(requestJson);
33+
const response =
34+
await fetch("https://eventarc.atlassian.net/rest/api/3/issue", {
35+
method: "POST",
36+
headers: {
37+
"Authorization": AUTH_HEADER,
38+
"Accept": "application/json",
39+
"Content-Type": "application/json",
40+
},
41+
body: requestBody,
42+
});
43+
if (!response.ok) {
44+
throw new Error("Issue creation failed: " +
45+
`${response.status} ${response.statusText} for ` +
46+
requestBody);
47+
}
48+
const json = await response.json();
49+
return json.self; // issueUri
50+
}
51+
52+
/**
53+
* Uploads screenshot to Jira (after downloading it from Firebase).
54+
* @param {string} issueUri URI of the Jira issue
55+
* @param {string} screenshotUri URI of the screenshot hosted by Firebase
56+
*/
57+
async function uploadScreenshot(issueUri, screenshotUri) {
58+
const dlResonse = await fetch(screenshotUri);
59+
if (!dlResonse.ok) {
60+
throw new Error("Screenshot download failed: " +
61+
`${dlResonse.status} ${dlResonse.statusText}`);
62+
}
63+
const blob = await dlResonse.blob();
64+
65+
const form = new FormData();
66+
form.append("file", blob, "screenshot.png");
67+
const ulResponse = await fetch(issueUri + "/attachments", {
68+
method: "POST",
69+
body: form,
70+
headers: {
71+
"Authorization": AUTH_HEADER,
72+
"Accept": "application/json",
73+
"X-Atlassian-Token": "no-check",
74+
},
75+
});
76+
if (!ulResponse.ok) {
77+
throw new Error("Screenshot upload failed: " +
78+
`${ulResponse.status} ${ulResponse.statusText}`);
79+
}
80+
}
81+
82+
/**
83+
* Looks up Jira user ID.
84+
* @param {string} testerEmail Email address of tester who filed feedback
85+
*/
86+
async function lookupReporter(testerEmail) {
87+
const response =
88+
await fetch(`${JIRA_URI}/rest/api/3/user/search?query=${testerEmail}`, {
89+
method: "GET",
90+
headers: {"Authorization": AUTH_HEADER, "Accept": "application/json"},
91+
});
92+
if (!response.ok) {
93+
logger.info(`Failed to find Jira user for '${testerEmail}':` +
94+
`${response.status} ${response.statusText}`);
95+
}
96+
const json = await response.json();
97+
return json.length > 0 ? json[0].accountId : undefined;
98+
}
99+
100+
/**
101+
* Builds payload for creating a Jira issue.
102+
* @param {AlertEvent<InAppFeedbackPayload>} event
103+
*/
104+
async function buildCreateIssueRequest(event) {
105+
let summary = "In-app feedback: " + event.data.payload.text;
106+
summary = summary.replace(/[\n\r].*/g, "");
107+
if (summary.length > 40) {
108+
summary = summary.substring(0, 39) + "…";
109+
}
110+
const json = {
111+
"update": {},
112+
"fields": {
113+
"summary": summary,
114+
"issuetype": {
115+
"id": ISSUE_TYPE_ID,
116+
},
117+
"project": {
118+
"key": PROJECT_KEY,
119+
},
120+
"description": {
121+
"type": "doc",
122+
"version": 1,
123+
"content": [
124+
{
125+
"type": "paragraph",
126+
"content": [
127+
{
128+
"text": "Firebase App ID: ",
129+
"type": "text",
130+
"marks": [
131+
{
132+
"type": "strong",
133+
},
134+
],
135+
},
136+
{
137+
"text": event.appId,
138+
"type": "text",
139+
},
140+
],
141+
},
142+
{
143+
"type": "paragraph",
144+
"content": [
145+
{
146+
"text": "App Version: ",
147+
"type": "text",
148+
"marks": [
149+
{
150+
"type": "strong",
151+
},
152+
],
153+
},
154+
{
155+
"text": event.data.payload.appVersion,
156+
"type": "text",
157+
},
158+
],
159+
},
160+
{
161+
"type": "paragraph",
162+
"content": [
163+
{
164+
"text": "Tester Email: ",
165+
"type": "text",
166+
"marks": [
167+
{
168+
"type": "strong",
169+
},
170+
],
171+
},
172+
{
173+
"text": event.data.payload.testerEmail,
174+
"type": "text",
175+
},
176+
],
177+
},
178+
{
179+
"type": "paragraph",
180+
"content": [
181+
{
182+
"text": "Tester Name: ",
183+
"type": "text",
184+
"marks": [
185+
{
186+
"type": "strong",
187+
},
188+
],
189+
},
190+
{
191+
"text": event.data.payload.testerName || "None",
192+
"type": "text",
193+
},
194+
],
195+
},
196+
{
197+
"type": "paragraph",
198+
"content": [
199+
{
200+
"text": "Feedback text: ",
201+
"type": "text",
202+
"marks": [
203+
{
204+
"type": "strong",
205+
},
206+
],
207+
},
208+
{
209+
"text": event.data.payload.text,
210+
"type": "text",
211+
},
212+
],
213+
},
214+
{
215+
"type": "paragraph",
216+
"content": [
217+
{
218+
"text": "Console link",
219+
"type": "text",
220+
"marks": [
221+
{
222+
"type": "link",
223+
"attrs": {
224+
"href": event.data.payload.feedbackConsoleUri,
225+
"title": "Firebase console",
226+
},
227+
},
228+
],
229+
},
230+
],
231+
},
232+
],
233+
},
234+
"labels": ISSUE_LABELS,
235+
},
236+
};
237+
const reporter = await lookupReporter(event.data.payload.testerEmail);
238+
if (reporter) {
239+
json.fields.reporter = {"id": reporter};
240+
}
241+
return json;
242+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "functions",
3+
"description": "File new issue in Jira when receiving in-app feedback facilitated by Firebase App Distribution",
4+
"scripts": {
5+
"lint": "eslint .",
6+
"serve": "firebase emulators:start --only functions",
7+
"shell": "firebase functions:shell",
8+
"start": "npm run shell",
9+
"deploy": "firebase deploy --only functions",
10+
"logs": "firebase functions:log"
11+
},
12+
"engines": {
13+
"node": "16"
14+
},
15+
"main": "index.js",
16+
"type": "module",
17+
"dependencies": {
18+
"firebase-functions": "^4.1.0",
19+
"formdata-polyfill": "^4.0.10",
20+
"node-fetch": "^3.3.0"
21+
},
22+
"devDependencies": {
23+
"eslint": "^8.9.0",
24+
"eslint-config-google": "^0.14.0"
25+
},
26+
"private": true
27+
}

0 commit comments

Comments
 (0)