@@ -2,6 +2,71 @@ import type { GroupedEventDBScheme, ProjectDBScheme } from '@hawk.so/types';
22import { decodeUnsafeFields } from '../../../../lib/utils/unsafeFields' ;
33import type { IssueData } from '../GithubService' ;
44
5+ /**
6+ * Format date for display in GitHub issue
7+ *
8+ * @param timestamp - Unix timestamp in seconds
9+ * @returns Formatted date string (e.g., "23 Feb 2025 14:40:21")
10+ */
11+ function formatDate ( timestamp : number ) : string {
12+ const date = new Date ( timestamp * 1000 ) ;
13+ const months = [ 'Jan' , 'Feb' , 'Mar' , 'Apr' , 'May' , 'Jun' , 'Jul' , 'Aug' , 'Sep' , 'Oct' , 'Nov' , 'Dec' ] ;
14+ const day = date . getUTCDate ( ) ;
15+ const month = months [ date . getUTCMonth ( ) ] ;
16+ const year = date . getUTCFullYear ( ) ;
17+ const hours = date . getUTCHours ( ) . toString ( ) . padStart ( 2 , '0' ) ;
18+ const minutes = date . getUTCMinutes ( ) . toString ( ) . padStart ( 2 , '0' ) ;
19+ const seconds = date . getUTCSeconds ( ) . toString ( ) . padStart ( 2 , '0' ) ;
20+
21+ return `${ day } ${ month } ${ year } ${ hours } :${ minutes } :${ seconds } ` ;
22+ }
23+
24+ /**
25+ * Calculate days repeating from timestamp
26+ *
27+ * @param timestamp - Unix timestamp in seconds
28+ * @returns Number of days since first occurrence
29+ */
30+ function calculateDaysRepeating ( timestamp : number ) : number {
31+ const now = Date . now ( ) ;
32+ const eventTimestamp = timestamp * 1000 ;
33+ const differenceInDays = ( now - eventTimestamp ) / ( 1000 * 3600 * 24 ) ;
34+
35+ return Math . round ( differenceInDays ) ;
36+ }
37+
38+ /**
39+ * Format source code as diff with line numbers
40+ * The error line is marked with minus sign, other lines with space
41+ *
42+ * @param sourceCode - Array of source code lines
43+ * @param errorLine - Line number where error occurred
44+ * @returns Formatted diff string
45+ */
46+ function formatSourceCodeAsDiff ( sourceCode : Array < { line : number ; content : string } > , errorLine : number ) : string {
47+ const lines : string [ ] = [ ] ;
48+
49+ for ( const sourceLine of sourceCode ) {
50+ const lineNumber = sourceLine . line . toString ( ) . padStart ( 3 , ' ' ) ;
51+ const isErrorLine = sourceLine . line === errorLine ;
52+ const prefix = isErrorLine ? '-' : ' ' ;
53+
54+ /**
55+ * Escape HTML entities in content
56+ */
57+ const escapedContent = sourceLine . content
58+ . replace ( / & / g, '&' )
59+ . replace ( / < / g, '<' )
60+ . replace ( / > / g, '>' )
61+ . replace ( / " / g, '"' )
62+ . replace ( / ' / g, ''' ) ;
63+
64+ lines . push ( `${ prefix } ${ lineNumber } : ${ escapedContent } ` ) ;
65+ }
66+
67+ return lines . join ( '\n' ) ;
68+ }
69+
570/**
671 * Format GitHub Issue from event
772 *
@@ -27,73 +92,136 @@ export function formatIssueFromEvent(event: GroupedEventDBScheme, project: Proje
2792 const title = `[Hawk] ${ decodedEvent . payload . title } ` ;
2893
2994 /**
30- * Format body with:
31- * - Link to event page in Hawk
32- * - totalCount
33- * - Stacktrace (top frames, truncated)
34- * - Technical marker: hawk_groupHash
95+ * Format body according to the template:
96+ * - H2 header
97+ * - Stacktrace with first frame expanded, others in details
98+ * - Table with event data
99+ * - Context and Addons as JSON
100+ * - Link to event in Hawk
35101 */
36102 const bodyParts : string [ ] = [ ] ;
37103
38104 /**
39- * Link to event page
40- */
41- bodyParts . push ( `**View in Hawk:** ${ eventUrl } ` ) ;
42-
43- /**
44- * Total count
105+ * H2 header with title
45106 */
46- bodyParts . push ( `\n**Total occurrences:** ${ decodedEvent . totalCount } ` ) ;
107+ bodyParts . push ( `## ${ decodedEvent . payload . title } ` ) ;
47108
48109 /**
49- * Stacktrace (top frames, truncated to 10 frames max)
110+ * Stacktrace section
50111 */
51112 if ( decodedEvent . payload . backtrace && decodedEvent . payload . backtrace . length > 0 ) {
52- bodyParts . push ( '\n**Stacktrace:**' ) ;
53- bodyParts . push ( '```' ) ;
113+ const firstFrame = decodedEvent . payload . backtrace [ 0 ] ;
114+ const file = firstFrame . file || '<unknown>' ;
115+ const line = firstFrame . line || 0 ;
116+ const column = firstFrame . column || 0 ;
117+ const func = firstFrame . function || '<anonymous>' ;
54118
55119 /**
56- * Maximum number of frames to show in issue
120+ * First frame - always visible
57121 */
58- const MAX_FRAMES_TO_SHOW = 10 ;
122+ bodyParts . push ( `\n- at ${ func } ( ${ file } : ${ line } : ${ column } )` ) ;
59123
60124 /**
61- * Take top frames and format them
125+ * Source code for first frame in diff format
62126 */
63- const topFrames = decodedEvent . payload . backtrace . slice ( 0 , MAX_FRAMES_TO_SHOW ) ;
127+ if ( firstFrame . sourceCode && firstFrame . sourceCode . length > 0 ) {
128+ bodyParts . push ( '\n```diff' ) ;
129+ bodyParts . push ( formatSourceCodeAsDiff ( firstFrame . sourceCode , line ) ) ;
130+ bodyParts . push ( '```' ) ;
131+ }
64132
65- for ( const frame of topFrames ) {
66- const file = frame . file || '<unknown>' ;
67- const line = frame . line || 0 ;
68- const column = frame . column || 0 ;
69- const func = frame . function || '<anonymous>' ;
133+ /**
134+ * Additional frames in details section
135+ */
136+ if ( decodedEvent . payload . backtrace . length > 1 ) {
137+ bodyParts . push ( '\n<details>' ) ;
138+ bodyParts . push ( ' <summary>View full stack trace</summary>' ) ;
139+ bodyParts . push ( ' \n' ) ;
140+
141+ for ( let i = 1 ; i < decodedEvent . payload . backtrace . length ; i ++ ) {
142+ const frame = decodedEvent . payload . backtrace [ i ] ;
143+ const frameFile = frame . file || '<unknown>' ;
144+ const frameLine = frame . line || 0 ;
145+ const frameColumn = frame . column || 0 ;
146+ const frameFunc = frame . function || '<anonymous>' ;
147+
148+ bodyParts . push ( `- at ${ frameFunc } (${ frameFile } :${ frameLine } :${ frameColumn } )` ) ;
149+
150+ /**
151+ * Source code for this frame in diff format
152+ */
153+ if ( frame . sourceCode && frame . sourceCode . length > 0 ) {
154+ bodyParts . push ( '\n```diff' ) ;
155+ bodyParts . push ( formatSourceCodeAsDiff ( frame . sourceCode , frameLine ) ) ;
156+ bodyParts . push ( '```' ) ;
157+ }
70158
71- bodyParts . push ( `at ${ func } (${ file } :${ line } :${ column } )` ) ;
159+ /**
160+ * Add newline between frames if not last
161+ */
162+ if ( i < decodedEvent . payload . backtrace . length - 1 ) {
163+ bodyParts . push ( '' ) ;
164+ }
165+ }
72166
73- /**
74- * Maximum number of source code lines to show per frame
75- */
76- const MAX_SOURCE_LINES_PER_FRAME = 3 ;
167+ bodyParts . push ( '\n</details>' ) ;
168+ }
169+ }
170+
171+ /**
172+ * Table with event data
173+ */
174+ const sinceDate = formatDate ( decodedEvent . timestamp ) ;
175+ const daysRepeating = calculateDaysRepeating ( decodedEvent . timestamp ) ;
77176
78- /**
79- * Add source code snippet if available (first 3 lines)
80- */
81- if ( frame . sourceCode && frame . sourceCode . length > 0 ) {
82- const sourceLines = frame . sourceCode . slice ( 0 , MAX_SOURCE_LINES_PER_FRAME ) ;
177+ bodyParts . push ( '\n| Param | Value |' ) ;
178+ bodyParts . push ( '| -- | :--: |' ) ;
179+ bodyParts . push ( `| Since | ${ sinceDate } |` ) ;
180+ bodyParts . push ( `| Days Repeating | ${ daysRepeating } |` ) ;
181+ bodyParts . push ( `| Total Occurrences | ${ decodedEvent . totalCount } |` ) ;
182+ bodyParts . push ( `| Users Affected | ${ decodedEvent . usersAffected || '-' } |` ) ;
83183
84- for ( const sourceLine of sourceLines ) {
85- bodyParts . push ( ` ${ sourceLine . line } : ${ sourceLine . content } ` ) ;
86- }
87- }
184+ /**
185+ * Context and Addons sections in details
186+ */
187+ if ( decodedEvent . payload . context || decodedEvent . payload . addons ) {
188+ bodyParts . push ( '\n<details>' ) ;
189+ bodyParts . push ( ' <summary>View Context and Addons</summary>' ) ;
190+ bodyParts . push ( ' \n' ) ;
191+
192+ /**
193+ * Context section
194+ */
195+ if ( decodedEvent . payload . context ) {
196+ bodyParts . push ( '### Context' ) ;
197+ bodyParts . push ( '\n```json' ) ;
198+ bodyParts . push ( JSON . stringify ( decodedEvent . payload . context , null , 2 ) ) ;
199+ bodyParts . push ( '```' ) ;
200+ }
201+
202+ /**
203+ * Addons section
204+ */
205+ if ( decodedEvent . payload . addons ) {
206+ bodyParts . push ( '\n### Addons' ) ;
207+ bodyParts . push ( '\n```json' ) ;
208+ bodyParts . push ( JSON . stringify ( decodedEvent . payload . addons , null , 2 ) ) ;
209+ bodyParts . push ( '```' ) ;
88210 }
89211
90- bodyParts . push ( '``` ' ) ;
212+ bodyParts . push ( '\n</details> ' ) ;
91213 }
92214
215+ /**
216+ * Link to event in Hawk
217+ */
218+ bodyParts . push ( '\n### Details' ) ;
219+ bodyParts . push ( `\n[View in Hawk](${ eventUrl } )` ) ;
220+
93221 /**
94222 * Technical marker for tracking
95223 */
96- bodyParts . push ( `\n<!-- hawk_groupHash: ${ event . groupHash } -->` ) ;
224+ bodyParts . push ( `\n\n <!-- hawk_groupHash: ${ event . groupHash } -->` ) ;
97225
98226 const body = bodyParts . join ( '\n' ) ;
99227
0 commit comments