@@ -571,3 +571,208 @@ describe("githubService.getStatus", () => {
571571 expect ( status . repoAccessError ) . toBeNull ( ) ;
572572 } ) ;
573573} ) ;
574+
575+ // ---------------------------------------------------------------------------
576+ // createRepository — POST /user/repos
577+ // ---------------------------------------------------------------------------
578+
579+ describe ( "githubService.createRepository" , ( ) => {
580+ beforeEach ( ( ) => {
581+ vi . clearAllMocks ( ) ;
582+ process . env . GITHUB_TOKEN = "ghp_env_token" ;
583+ } ) ;
584+
585+ function lastFetchCall ( ) {
586+ const calls = mockFetch . mock . calls ;
587+ return calls [ calls . length - 1 ] as [ string , RequestInit ] ;
588+ }
589+
590+ it ( "POSTs the canonical body shape and parses the response" , async ( ) => {
591+ mockFetch . mockResolvedValueOnce (
592+ jsonResponse ( 201 , {
593+ clone_url : "https://github.com/alice/test.git" ,
594+ ssh_url : "git@github.com:alice/test.git" ,
595+ html_url : "https://github.com/alice/test" ,
596+ default_branch : "main" ,
597+ } ) ,
598+ ) ;
599+
600+ const result = await makeService ( ) . createRepository ( {
601+ name : "test" ,
602+ description : "hello world" ,
603+ isPrivate : true ,
604+ } ) ;
605+
606+ const [ url , init ] = lastFetchCall ( ) ;
607+ expect ( url ) . toContain ( "/user/repos" ) ;
608+ expect ( init . method ) . toBe ( "POST" ) ;
609+ expect ( JSON . parse ( init . body as string ) ) . toEqual ( {
610+ name : "test" ,
611+ description : "hello world" ,
612+ private : true ,
613+ auto_init : false ,
614+ } ) ;
615+ expect ( result ) . toEqual ( {
616+ cloneUrl : "https://github.com/alice/test.git" ,
617+ sshUrl : "git@github.com:alice/test.git" ,
618+ htmlUrl : "https://github.com/alice/test" ,
619+ defaultBranch : "main" ,
620+ } ) ;
621+ } ) ;
622+
623+ it ( "omits the description field when blank" , async ( ) => {
624+ mockFetch . mockResolvedValueOnce (
625+ jsonResponse ( 201 , {
626+ clone_url : "https://github.com/alice/test.git" ,
627+ ssh_url : "git@github.com:alice/test.git" ,
628+ html_url : "https://github.com/alice/test" ,
629+ default_branch : "main" ,
630+ } ) ,
631+ ) ;
632+
633+ await makeService ( ) . createRepository ( { name : "test" , isPrivate : false } ) ;
634+
635+ const [ , init ] = lastFetchCall ( ) ;
636+ const body = JSON . parse ( init . body as string ) ;
637+ expect ( body ) . not . toHaveProperty ( "description" ) ;
638+ expect ( body . private ) . toBe ( false ) ;
639+ } ) ;
640+
641+ it ( "propagates GitHub error messages on 4xx" , async ( ) => {
642+ mockFetch . mockResolvedValueOnce (
643+ jsonResponse ( 422 , {
644+ message : "Validation Failed" ,
645+ errors : [ { message : "name already exists on this account" } ] ,
646+ } ) ,
647+ ) ;
648+
649+ await expect (
650+ makeService ( ) . createRepository ( { name : "existing" , isPrivate : true } ) ,
651+ ) . rejects . toThrow ( / v a l i d a t i o n f a i l e d .* a l r e a d y e x i s t s / i) ;
652+ } ) ;
653+ } ) ;
654+
655+ // ---------------------------------------------------------------------------
656+ // publishCurrentProject — orchestrates createRepo + remote add + push
657+ // ---------------------------------------------------------------------------
658+
659+ describe ( "githubService.publishCurrentProject" , ( ) => {
660+ beforeEach ( ( ) => {
661+ vi . clearAllMocks ( ) ;
662+ process . env . GITHUB_TOKEN = "ghp_env_token" ;
663+ } ) ;
664+
665+ it ( "returns state=pushed when HEAD exists, after creating the repo and pushing" , async ( ) => {
666+ runGitMock
667+ // get-url origin: no remote yet
668+ . mockResolvedValueOnce ( { exitCode : 1 , stdout : "" , stderr : "fatal: No such remote 'origin'" } )
669+ // remote add origin: ok
670+ . mockResolvedValueOnce ( { exitCode : 0 , stdout : "" , stderr : "" } )
671+ // rev-parse HEAD: ok (commit exists)
672+ . mockResolvedValueOnce ( { exitCode : 0 , stdout : "abc123\n" , stderr : "" } )
673+ // push -u origin HEAD: ok
674+ . mockResolvedValueOnce ( { exitCode : 0 , stdout : "" , stderr : "" } ) ;
675+
676+ mockFetch . mockResolvedValueOnce (
677+ jsonResponse ( 201 , {
678+ clone_url : "https://github.com/alice/proj.git" ,
679+ ssh_url : "git@github.com:alice/proj.git" ,
680+ html_url : "https://github.com/alice/proj" ,
681+ default_branch : "main" ,
682+ } ) ,
683+ ) ;
684+
685+ const result = await makeService ( ) . publishCurrentProject ( {
686+ name : "proj" ,
687+ isPrivate : true ,
688+ } ) ;
689+
690+ expect ( result ) . toEqual ( { state : "pushed" , htmlUrl : "https://github.com/alice/proj" } ) ;
691+ const gitCalls = runGitMock . mock . calls . map ( ( c ) => c [ 0 ] ) ;
692+ expect ( gitCalls [ 0 ] ) . toEqual ( [ "remote" , "get-url" , "origin" ] ) ;
693+ expect ( gitCalls [ 1 ] ) . toEqual ( [ "remote" , "add" , "origin" , "https://github.com/alice/proj.git" ] ) ;
694+ expect ( gitCalls [ 2 ] ) . toEqual ( [ "rev-parse" , "--verify" , "HEAD" ] ) ;
695+ expect ( gitCalls [ 3 ] ) . toEqual ( [ "push" , "-u" , "origin" , "HEAD" ] ) ;
696+ } ) ;
697+
698+ it ( "returns state=remote_added when the project has no commits yet" , async ( ) => {
699+ runGitMock
700+ . mockResolvedValueOnce ( { exitCode : 1 , stdout : "" , stderr : "" } ) // get-url origin
701+ . mockResolvedValueOnce ( { exitCode : 0 , stdout : "" , stderr : "" } ) // remote add
702+ . mockResolvedValueOnce ( { exitCode : 128 , stdout : "" , stderr : "fatal: Needed a single revision" } ) ; // rev-parse HEAD fails
703+
704+ mockFetch . mockResolvedValueOnce (
705+ jsonResponse ( 201 , {
706+ clone_url : "https://github.com/alice/empty.git" ,
707+ ssh_url : "git@github.com:alice/empty.git" ,
708+ html_url : "https://github.com/alice/empty" ,
709+ default_branch : "main" ,
710+ } ) ,
711+ ) ;
712+
713+ const result = await makeService ( ) . publishCurrentProject ( {
714+ name : "empty" ,
715+ isPrivate : true ,
716+ } ) ;
717+
718+ expect ( result ) . toEqual ( { state : "remote_added" , htmlUrl : "https://github.com/alice/empty" } ) ;
719+ // Should NOT have called push
720+ expect ( runGitMock . mock . calls . map ( ( c ) => ( c [ 0 ] as string [ ] ) [ 0 ] ) ) . not . toContain ( "push" ) ;
721+ } ) ;
722+
723+ it ( "throws remote_already_exists when origin is already configured" , async ( ) => {
724+ runGitMock . mockResolvedValueOnce ( {
725+ exitCode : 0 ,
726+ stdout : "git@github.com:someone/already.git\n" ,
727+ stderr : "" ,
728+ } ) ;
729+
730+ let caught : any ;
731+ try {
732+ await makeService ( ) . publishCurrentProject ( { name : "x" , isPrivate : true } ) ;
733+ } catch ( err ) {
734+ caught = err ;
735+ }
736+ expect ( caught ) . toBeInstanceOf ( Error ) ;
737+ expect ( caught . code ) . toBe ( "remote_already_exists" ) ;
738+ // Must NOT have hit the API
739+ expect ( mockFetch ) . not . toHaveBeenCalled ( ) ;
740+ } ) ;
741+
742+ it ( "throws github_not_connected when no token is stored" , async ( ) => {
743+ delete process . env . GITHUB_TOKEN ;
744+ delete process . env . ADE_GITHUB_TOKEN ;
745+
746+ let caught : any ;
747+ try {
748+ await makeService ( ) . publishCurrentProject ( { name : "x" , isPrivate : true } ) ;
749+ } catch ( err ) {
750+ caught = err ;
751+ }
752+ expect ( caught ) . toBeInstanceOf ( Error ) ;
753+ expect ( caught . code ) . toBe ( "github_not_connected" ) ;
754+ expect ( runGitMock ) . not . toHaveBeenCalled ( ) ;
755+ expect ( mockFetch ) . not . toHaveBeenCalled ( ) ;
756+ } ) ;
757+
758+ it ( "surfaces a clear error when the push step fails" , async ( ) => {
759+ runGitMock
760+ . mockResolvedValueOnce ( { exitCode : 1 , stdout : "" , stderr : "" } )
761+ . mockResolvedValueOnce ( { exitCode : 0 , stdout : "" , stderr : "" } )
762+ . mockResolvedValueOnce ( { exitCode : 0 , stdout : "abc\n" , stderr : "" } )
763+ . mockResolvedValueOnce ( { exitCode : 1 , stdout : "" , stderr : "Authentication failed" } ) ;
764+
765+ mockFetch . mockResolvedValueOnce (
766+ jsonResponse ( 201 , {
767+ clone_url : "https://github.com/alice/proj.git" ,
768+ ssh_url : "git@github.com:alice/proj.git" ,
769+ html_url : "https://github.com/alice/proj" ,
770+ default_branch : "main" ,
771+ } ) ,
772+ ) ;
773+
774+ await expect (
775+ makeService ( ) . publishCurrentProject ( { name : "proj" , isPrivate : true } ) ,
776+ ) . rejects . toThrow ( / a u t h e n t i c a t i o n f a i l e d / i) ;
777+ } ) ;
778+ } ) ;
0 commit comments