@@ -447,4 +447,237 @@ describe("createMentionHandler write intent gating", () => {
447447
448448 await workspaceFixture . cleanup ( ) ;
449449 } ) ;
450+
451+ test ( "write intent is refused when a staged path is denied" , async ( ) => {
452+ const handlers = new Map < string , ( event : WebhookEvent ) => Promise < void > > ( ) ;
453+ const workspaceFixture = await createWorkspaceFixture (
454+ "mention:\n enabled: true\nwrite:\n enabled: true\n denyPaths:\n - 'README.md'\n" ,
455+ ) ;
456+
457+ const prNumber = 101 ;
458+ const featureSha = ( await $ `git -C ${ workspaceFixture . dir } rev-parse feature` . quiet ( ) )
459+ . text ( )
460+ . trim ( ) ;
461+ await $ `git --git-dir ${ workspaceFixture . remoteDir } update-ref refs/pull/${ prNumber } /head ${ featureSha } ` . quiet ( ) ;
462+
463+ let createdPr = false ;
464+ let replyBody : string | undefined ;
465+
466+ const eventRouter : EventRouter = {
467+ register : ( eventKey , handler ) => {
468+ handlers . set ( eventKey , handler ) ;
469+ } ,
470+ dispatch : async ( ) => undefined ,
471+ } ;
472+
473+ const jobQueue : JobQueue = {
474+ enqueue : async < T > ( _installationId : number , fn : ( ) => Promise < T > ) => fn ( ) ,
475+ getQueueSize : ( ) => 0 ,
476+ getPendingCount : ( ) => 0 ,
477+ } ;
478+
479+ const workspaceManager : WorkspaceManager = {
480+ create : async ( _installationId : number , options : CloneOptions ) => {
481+ await $ `git -C ${ workspaceFixture . dir } checkout ${ options . ref } ` . quiet ( ) ;
482+ return { dir : workspaceFixture . dir , cleanup : async ( ) => undefined } ;
483+ } ,
484+ cleanupStale : async ( ) => 0 ,
485+ } ;
486+
487+ const octokit = {
488+ rest : {
489+ reactions : {
490+ createForPullRequestReviewComment : async ( ) => ( { data : { } } ) ,
491+ createForIssueComment : async ( ) => ( { data : { } } ) ,
492+ } ,
493+ issues : {
494+ listComments : async ( ) => ( { data : [ ] } ) ,
495+ createComment : async ( ) => ( { data : { } } ) ,
496+ } ,
497+ pulls : {
498+ get : async ( ) => ( {
499+ data : {
500+ title : "Test PR" ,
501+ body : "" ,
502+ user : { login : "octocat" } ,
503+ head : { ref : "feature" } ,
504+ base : { ref : "main" } ,
505+ } ,
506+ } ) ,
507+ create : async ( ) => {
508+ createdPr = true ;
509+ return { data : { html_url : "https://example.com/pr/123" } } ;
510+ } ,
511+ createReplyForReviewComment : async ( params : { body : string } ) => {
512+ replyBody = params . body ;
513+ return { data : { } } ;
514+ } ,
515+ } ,
516+ } ,
517+ } ;
518+
519+ createMentionHandler ( {
520+ eventRouter,
521+ jobQueue,
522+ workspaceManager,
523+ githubApp : {
524+ getAppSlug : ( ) => "kodiai" ,
525+ getInstallationOctokit : async ( ) => octokit as never ,
526+ } as unknown as GitHubApp ,
527+ executor : {
528+ execute : async ( ctx : { workspace : { dir : string } } ) => {
529+ await Bun . write ( join ( ctx . workspace . dir , "README.md" ) , "base\nfeature\nchanged\n" ) ;
530+ return {
531+ conclusion : "success" ,
532+ published : false ,
533+ costUsd : 0 ,
534+ numTurns : 1 ,
535+ durationMs : 1 ,
536+ sessionId : "session-mention" ,
537+ } ;
538+ } ,
539+ } as never ,
540+ logger : createNoopLogger ( ) ,
541+ } ) ;
542+
543+ const handler = handlers . get ( "pull_request_review_comment.created" ) ;
544+ expect ( handler ) . toBeDefined ( ) ;
545+
546+ await handler ! (
547+ buildReviewCommentMentionEvent ( {
548+ prNumber,
549+ baseRef : "main" ,
550+ headRef : "feature" ,
551+ headRepoOwner : "forker" ,
552+ headRepoName : "repo" ,
553+ commentBody : "@kodiai apply: update the README" ,
554+ } ) ,
555+ ) ;
556+
557+ expect ( createdPr ) . toBe ( false ) ;
558+ expect ( replyBody ) . toBeDefined ( ) ;
559+ expect ( replyBody ! ) . toContain ( "Write request refused" ) ;
560+ expect ( replyBody ! ) . toContain ( "denied path" ) ;
561+
562+ await workspaceFixture . cleanup ( ) ;
563+ } ) ;
564+
565+ test ( "write intent requests are rate-limited when configured" , async ( ) => {
566+ const handlers = new Map < string , ( event : WebhookEvent ) => Promise < void > > ( ) ;
567+ const workspaceFixture = await createWorkspaceFixture (
568+ "mention:\n enabled: true\nwrite:\n enabled: true\n minIntervalSeconds: 60\n" ,
569+ ) ;
570+
571+ const prNumber = 101 ;
572+ const featureSha = ( await $ `git -C ${ workspaceFixture . dir } rev-parse feature` . quiet ( ) )
573+ . text ( )
574+ . trim ( ) ;
575+ await $ `git --git-dir ${ workspaceFixture . remoteDir } update-ref refs/pull/${ prNumber } /head ${ featureSha } ` . quiet ( ) ;
576+
577+ const replies : string [ ] = [ ] ;
578+ let prCreates = 0 ;
579+
580+ const eventRouter : EventRouter = {
581+ register : ( eventKey , handler ) => {
582+ handlers . set ( eventKey , handler ) ;
583+ } ,
584+ dispatch : async ( ) => undefined ,
585+ } ;
586+
587+ const jobQueue : JobQueue = {
588+ enqueue : async < T > ( _installationId : number , fn : ( ) => Promise < T > ) => fn ( ) ,
589+ getQueueSize : ( ) => 0 ,
590+ getPendingCount : ( ) => 0 ,
591+ } ;
592+
593+ const workspaceManager : WorkspaceManager = {
594+ create : async ( _installationId : number , options : CloneOptions ) => {
595+ await $ `git -C ${ workspaceFixture . dir } checkout ${ options . ref } ` . quiet ( ) ;
596+ return { dir : workspaceFixture . dir , cleanup : async ( ) => undefined } ;
597+ } ,
598+ cleanupStale : async ( ) => 0 ,
599+ } ;
600+
601+ const octokit = {
602+ rest : {
603+ reactions : {
604+ createForPullRequestReviewComment : async ( ) => ( { data : { } } ) ,
605+ createForIssueComment : async ( ) => ( { data : { } } ) ,
606+ } ,
607+ issues : {
608+ listComments : async ( ) => ( { data : [ ] } ) ,
609+ createComment : async ( ) => ( { data : { } } ) ,
610+ } ,
611+ pulls : {
612+ get : async ( ) => ( {
613+ data : {
614+ title : "Test PR" ,
615+ body : "" ,
616+ user : { login : "octocat" } ,
617+ head : { ref : "feature" } ,
618+ base : { ref : "main" } ,
619+ } ,
620+ } ) ,
621+ create : async ( ) => {
622+ prCreates ++ ;
623+ return { data : { html_url : "https://example.com/pr/123" } } ;
624+ } ,
625+ createReplyForReviewComment : async ( params : { body : string } ) => {
626+ replies . push ( params . body ) ;
627+ return { data : { } } ;
628+ } ,
629+ } ,
630+ } ,
631+ } ;
632+
633+ let writeCount = 0 ;
634+ createMentionHandler ( {
635+ eventRouter,
636+ jobQueue,
637+ workspaceManager,
638+ githubApp : {
639+ getAppSlug : ( ) => "kodiai" ,
640+ getInstallationOctokit : async ( ) => octokit as never ,
641+ } as unknown as GitHubApp ,
642+ executor : {
643+ execute : async ( ctx : { workspace : { dir : string } } ) => {
644+ writeCount ++ ;
645+ await Bun . write (
646+ join ( ctx . workspace . dir , "README.md" ) ,
647+ `base\nfeature\nchanged-${ writeCount } \n` ,
648+ ) ;
649+ return {
650+ conclusion : "success" ,
651+ published : false ,
652+ costUsd : 0 ,
653+ numTurns : 1 ,
654+ durationMs : 1 ,
655+ sessionId : "session-mention" ,
656+ } ;
657+ } ,
658+ } as never ,
659+ logger : createNoopLogger ( ) ,
660+ } ) ;
661+
662+ const handler = handlers . get ( "pull_request_review_comment.created" ) ;
663+ expect ( handler ) . toBeDefined ( ) ;
664+
665+ const event = buildReviewCommentMentionEvent ( {
666+ prNumber,
667+ baseRef : "main" ,
668+ headRef : "feature" ,
669+ headRepoOwner : "forker" ,
670+ headRepoName : "repo" ,
671+ commentBody : "@kodiai apply: update the README" ,
672+ } ) ;
673+
674+ await handler ! ( event ) ;
675+ await handler ! ( event ) ;
676+
677+ expect ( prCreates ) . toBe ( 1 ) ;
678+ expect ( replies ) . toHaveLength ( 2 ) ;
679+ expect ( replies [ 1 ] ! ) . toContain ( "rate-limited" ) ;
680+
681+ await workspaceFixture . cleanup ( ) ;
682+ } ) ;
450683} ) ;
0 commit comments